vellum 0.2.2 → 0.2.8

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 (60) hide show
  1. package/bun.lock +68 -100
  2. package/package.json +3 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +6 -0
  6. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  7. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  8. package/src/__tests__/ipc-snapshot.test.ts +20 -0
  9. package/src/__tests__/memory-regressions.test.ts +100 -2
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +342 -0
  13. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  14. package/src/__tests__/session-conflict-gate.test.ts +28 -25
  15. package/src/__tests__/tool-executor.test.ts +88 -0
  16. package/src/__tests__/turn-commit.test.ts +64 -0
  17. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  18. package/src/calls/call-domain.ts +3 -3
  19. package/src/calls/twilio-config.ts +25 -9
  20. package/src/calls/twilio-provider.ts +4 -4
  21. package/src/calls/twilio-routes.ts +10 -2
  22. package/src/calls/twilio-webhook-urls.ts +47 -0
  23. package/src/cli/map.ts +30 -6
  24. package/src/config/defaults.ts +5 -0
  25. package/src/config/schema.ts +34 -2
  26. package/src/config/system-prompt.ts +1 -1
  27. package/src/config/types.ts +1 -0
  28. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  29. package/src/daemon/computer-use-session.ts +2 -1
  30. package/src/daemon/handlers/config.ts +95 -4
  31. package/src/daemon/handlers/sessions.ts +2 -2
  32. package/src/daemon/handlers/work-items.ts +1 -1
  33. package/src/daemon/ipc-contract-inventory.json +8 -0
  34. package/src/daemon/ipc-contract.ts +39 -1
  35. package/src/daemon/ride-shotgun-handler.ts +2 -1
  36. package/src/daemon/session-agent-loop.ts +37 -2
  37. package/src/daemon/session-conflict-gate.ts +18 -109
  38. package/src/daemon/session-tool-setup.ts +7 -0
  39. package/src/inbound/public-ingress-urls.ts +106 -0
  40. package/src/memory/attachments-store.ts +0 -1
  41. package/src/memory/channel-delivery-store.ts +0 -1
  42. package/src/memory/conflict-intent.ts +114 -0
  43. package/src/memory/conversation-key-store.ts +0 -1
  44. package/src/memory/db.ts +346 -149
  45. package/src/memory/job-handlers/conflict.ts +23 -1
  46. package/src/memory/runs-store.ts +0 -3
  47. package/src/memory/schema.ts +0 -4
  48. package/src/runtime/gateway-client.ts +36 -0
  49. package/src/runtime/http-server.ts +140 -2
  50. package/src/runtime/routes/channel-routes.ts +121 -79
  51. package/src/security/oauth-callback-registry.ts +56 -0
  52. package/src/security/oauth2.ts +174 -58
  53. package/src/swarm/backend-claude-code.ts +1 -1
  54. package/src/tools/assets/search.ts +1 -36
  55. package/src/tools/browser/api-map.ts +123 -50
  56. package/src/tools/claude-code/claude-code.ts +131 -1
  57. package/src/tools/tasks/work-item-list.ts +16 -2
  58. package/src/workspace/commit-message-enrichment-service.ts +3 -3
  59. package/src/workspace/provider-commit-message-generator.ts +57 -14
  60. package/src/workspace/turn-commit.ts +6 -2
@@ -0,0 +1,221 @@
1
+ import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import * as net from 'node:net';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
8
+
9
+ let rawConfigStore: Record<string, unknown> = {};
10
+ const saveRawConfigCalls: Record<string, unknown>[] = [];
11
+
12
+ mock.module('../config/loader.js', () => ({
13
+ getConfig: () => ({}),
14
+ loadConfig: () => ({}),
15
+ loadRawConfig: () => ({ ...rawConfigStore }),
16
+ saveRawConfig: (cfg: Record<string, unknown>) => {
17
+ saveRawConfigCalls.push(cfg);
18
+ rawConfigStore = { ...cfg };
19
+ },
20
+ saveConfig: () => {},
21
+ invalidateConfigCache: () => {},
22
+ }));
23
+
24
+ mock.module('../util/platform.js', () => ({
25
+ getRootDir: () => testDir,
26
+ getDataDir: () => testDir,
27
+ getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
28
+ isMacOS: () => process.platform === 'darwin',
29
+ isLinux: () => process.platform === 'linux',
30
+ isWindows: () => process.platform === 'win32',
31
+ getSocketPath: () => join(testDir, 'test.sock'),
32
+ getPidPath: () => join(testDir, 'test.pid'),
33
+ getDbPath: () => join(testDir, 'test.db'),
34
+ getLogPath: () => join(testDir, 'test.log'),
35
+ ensureDataDir: () => {},
36
+ }));
37
+
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () => ({
40
+ info: () => {},
41
+ warn: () => {},
42
+ error: () => {},
43
+ debug: () => {},
44
+ trace: () => {},
45
+ fatal: () => {},
46
+ child: () => ({
47
+ info: () => {},
48
+ warn: () => {},
49
+ error: () => {},
50
+ debug: () => {},
51
+ }),
52
+ }),
53
+ }));
54
+
55
+ mock.module('../memory/app-store.js', () => ({
56
+ queryAppRecords: () => [],
57
+ createAppRecord: () => {},
58
+ updateAppRecord: () => {},
59
+ deleteAppRecord: () => {},
60
+ listApps: () => [],
61
+ getApp: () => undefined,
62
+ createApp: () => {},
63
+ updateApp: () => {},
64
+ }));
65
+
66
+ mock.module('../slack/slack-webhook.js', () => ({
67
+ postToSlackWebhook: async () => {},
68
+ }));
69
+
70
+ import { handleMessage, type HandlerContext } from '../daemon/handlers.js';
71
+ import type {
72
+ TwilioWebhookConfigRequest,
73
+ ServerMessage,
74
+ } from '../daemon/ipc-contract.js';
75
+
76
+ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
77
+ const sent: ServerMessage[] = [];
78
+ const ctx: HandlerContext = {
79
+ sessions: new Map(),
80
+ socketToSession: new Map(),
81
+ cuSessions: new Map(),
82
+ socketToCuSession: new Map(),
83
+ cuObservationParseSequence: new Map(),
84
+ socketSandboxOverride: new Map(),
85
+ sharedRequestTimestamps: [],
86
+ debounceTimers: new Map(),
87
+ suppressConfigReload: false,
88
+ setSuppressConfigReload: () => {},
89
+ updateConfigFingerprint: () => {},
90
+ send: (_socket, msg) => { sent.push(msg); },
91
+ broadcast: () => {},
92
+ clearAllSessions: () => 0,
93
+ getOrCreateSession: () => { throw new Error('not implemented'); },
94
+ touchSession: () => {},
95
+ };
96
+ return { ctx, sent };
97
+ }
98
+
99
+ describe('Twilio webhook config handler', () => {
100
+ beforeEach(() => {
101
+ rawConfigStore = {};
102
+ saveRawConfigCalls.length = 0;
103
+ });
104
+
105
+ test('get returns empty string when no config set', () => {
106
+ rawConfigStore = {};
107
+
108
+ const msg: TwilioWebhookConfigRequest = {
109
+ type: 'twilio_webhook_config',
110
+ action: 'get',
111
+ };
112
+
113
+ const { ctx, sent } = createTestContext();
114
+ handleMessage(msg, {} as net.Socket, ctx);
115
+
116
+ expect(sent).toHaveLength(1);
117
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
118
+ expect(res.type).toBe('twilio_webhook_config_response');
119
+ expect(res.success).toBe(true);
120
+ expect(res.webhookBaseUrl).toBe('');
121
+ });
122
+
123
+ test('set persists value and returns it', () => {
124
+ rawConfigStore = {};
125
+
126
+ const msg: TwilioWebhookConfigRequest = {
127
+ type: 'twilio_webhook_config',
128
+ action: 'set',
129
+ webhookBaseUrl: 'https://example.com/twilio',
130
+ };
131
+
132
+ const { ctx, sent } = createTestContext();
133
+ handleMessage(msg, {} as net.Socket, ctx);
134
+
135
+ expect(sent).toHaveLength(1);
136
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
137
+ expect(res.type).toBe('twilio_webhook_config_response');
138
+ expect(res.success).toBe(true);
139
+ expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
140
+
141
+ expect(saveRawConfigCalls).toHaveLength(1);
142
+ const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
143
+ expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
144
+ });
145
+
146
+ test('set normalizes trailing slashes', () => {
147
+ rawConfigStore = {};
148
+
149
+ const msg: TwilioWebhookConfigRequest = {
150
+ type: 'twilio_webhook_config',
151
+ action: 'set',
152
+ webhookBaseUrl: 'https://example.com/twilio///',
153
+ };
154
+
155
+ const { ctx, sent } = createTestContext();
156
+ handleMessage(msg, {} as net.Socket, ctx);
157
+
158
+ expect(sent).toHaveLength(1);
159
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
160
+ expect(res.webhookBaseUrl).toBe('https://example.com/twilio');
161
+
162
+ const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
163
+ expect(saved.calls?.webhookBaseUrl).toBe('https://example.com/twilio');
164
+ });
165
+
166
+ test('set treats empty string as unset', () => {
167
+ rawConfigStore = { calls: { webhookBaseUrl: 'https://example.com/twilio' } };
168
+ saveRawConfigCalls.length = 0;
169
+
170
+ const msg: TwilioWebhookConfigRequest = {
171
+ type: 'twilio_webhook_config',
172
+ action: 'set',
173
+ webhookBaseUrl: '',
174
+ };
175
+
176
+ const { ctx, sent } = createTestContext();
177
+ handleMessage(msg, {} as net.Socket, ctx);
178
+
179
+ expect(sent).toHaveLength(1);
180
+ const res = sent[0] as { type: string; webhookBaseUrl: string; success: boolean };
181
+ expect(res.success).toBe(true);
182
+ expect(res.webhookBaseUrl).toBe('');
183
+
184
+ const saved = saveRawConfigCalls[0] as { calls?: { webhookBaseUrl?: string } };
185
+ expect(saved.calls?.webhookBaseUrl).toBeUndefined();
186
+ });
187
+
188
+ test('get after set roundtrip works', () => {
189
+ rawConfigStore = {};
190
+
191
+ // Set
192
+ const setMsg: TwilioWebhookConfigRequest = {
193
+ type: 'twilio_webhook_config',
194
+ action: 'set',
195
+ webhookBaseUrl: 'https://my-server.ngrok.io',
196
+ };
197
+
198
+ const { ctx: setCtx, sent: setSent } = createTestContext();
199
+ handleMessage(setMsg, {} as net.Socket, setCtx);
200
+
201
+ expect(setSent).toHaveLength(1);
202
+ const setRes = setSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
203
+ expect(setRes.success).toBe(true);
204
+ expect(setRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
205
+
206
+ // Get (rawConfigStore was updated by the mock saveRawConfig)
207
+ const getMsg: TwilioWebhookConfigRequest = {
208
+ type: 'twilio_webhook_config',
209
+ action: 'get',
210
+ };
211
+
212
+ const { ctx: getCtx, sent: getSent } = createTestContext();
213
+ handleMessage(getMsg, {} as net.Socket, getCtx);
214
+
215
+ expect(getSent).toHaveLength(1);
216
+ const getRes = getSent[0] as { type: string; webhookBaseUrl: string; success: boolean };
217
+ expect(getRes.type).toBe('twilio_webhook_config_response');
218
+ expect(getRes.success).toBe(true);
219
+ expect(getRes.webhookBaseUrl).toBe('https://my-server.ngrok.io');
220
+ });
221
+ });
@@ -339,6 +339,10 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
339
339
  type: 'slack_webhook_config',
340
340
  action: 'get',
341
341
  },
342
+ twilio_webhook_config: {
343
+ type: 'twilio_webhook_config',
344
+ action: 'get',
345
+ },
342
346
  vercel_api_config: {
343
347
  type: 'vercel_api_config',
344
348
  action: 'get',
@@ -1125,6 +1129,11 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1125
1129
  webhookUrl: 'https://hooks.slack.com/services/T00/B00/xxx',
1126
1130
  success: true,
1127
1131
  },
1132
+ twilio_webhook_config_response: {
1133
+ type: 'twilio_webhook_config_response',
1134
+ webhookBaseUrl: 'https://example.com/twilio',
1135
+ success: true,
1136
+ },
1128
1137
  vercel_api_config_response: {
1129
1138
  type: 'vercel_api_config_response',
1130
1139
  hasToken: true,
@@ -1420,6 +1429,17 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1420
1429
  sessionId: 'sub-sess-001',
1421
1430
  },
1422
1431
  },
1432
+ agent_heartbeat_alert: {
1433
+ type: 'agent_heartbeat_alert',
1434
+ title: 'Agent unresponsive',
1435
+ body: 'The agent has not responded for 5 minutes.',
1436
+ },
1437
+ task_run_thread_created: {
1438
+ type: 'task_run_thread_created',
1439
+ conversationId: 'conv-task-001',
1440
+ workItemId: 'work-item-001',
1441
+ title: 'Task run thread',
1442
+ },
1423
1443
  };
1424
1444
 
1425
1445
  // ---------------------------------------------------------------------------
@@ -949,7 +949,7 @@ describe('Memory regressions', () => {
949
949
  id: 'msg-conflicts-bg',
950
950
  conversationId: 'conv-conflicts-bg',
951
951
  role: 'user',
952
- content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
952
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new MySQL default instead.' }]),
953
953
  createdAt: now + 1,
954
954
  }).run();
955
955
 
@@ -1047,7 +1047,7 @@ describe('Memory regressions', () => {
1047
1047
  id: 'msg-conflicts-age',
1048
1048
  conversationId: 'conv-conflicts-age',
1049
1049
  role: 'user',
1050
- content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
1050
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new Bun runtime instead.' }]),
1051
1051
  createdAt: now + 1,
1052
1052
  }).run();
1053
1053
 
@@ -1118,6 +1118,104 @@ describe('Memory regressions', () => {
1118
1118
  }
1119
1119
  });
1120
1120
 
1121
+ test('background conflict resolver ignores clarification-like replies with no topical overlap when conflict was never asked', async () => {
1122
+ const db = getDb();
1123
+ const now = 1_700_001_400_000;
1124
+ const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1125
+ TEST_CONFIG.memory.conflicts.enabled = true;
1126
+
1127
+ try {
1128
+ db.insert(conversations).values({
1129
+ id: 'conv-conflicts-unrelated',
1130
+ title: null,
1131
+ createdAt: now,
1132
+ updatedAt: now,
1133
+ totalInputTokens: 0,
1134
+ totalOutputTokens: 0,
1135
+ totalEstimatedCost: 0,
1136
+ contextSummary: null,
1137
+ contextCompactedMessageCount: 0,
1138
+ contextCompactedAt: null,
1139
+ }).run();
1140
+
1141
+ db.insert(messages).values({
1142
+ id: 'msg-conflicts-unrelated',
1143
+ conversationId: 'conv-conflicts-unrelated',
1144
+ role: 'user',
1145
+ content: JSON.stringify([{ type: 'text', text: 'Keep the new one instead.' }]),
1146
+ createdAt: now + 1,
1147
+ }).run();
1148
+
1149
+ db.insert(memoryItems).values([
1150
+ {
1151
+ id: 'item-conflict-existing-unrelated',
1152
+ kind: 'preference',
1153
+ subject: 'database',
1154
+ statement: 'Use Postgres by default.',
1155
+ status: 'active',
1156
+ confidence: 0.8,
1157
+ fingerprint: 'fp-conflict-existing-unrelated',
1158
+ verificationState: 'assistant_inferred',
1159
+ scopeId: 'scope-conflicts-unrelated',
1160
+ firstSeenAt: now - 10_000,
1161
+ lastSeenAt: now - 5_000,
1162
+ validFrom: now - 10_000,
1163
+ invalidAt: null,
1164
+ },
1165
+ {
1166
+ id: 'item-conflict-candidate-unrelated',
1167
+ kind: 'preference',
1168
+ subject: 'database',
1169
+ statement: 'Use MySQL by default.',
1170
+ status: 'pending_clarification',
1171
+ confidence: 0.8,
1172
+ fingerprint: 'fp-conflict-candidate-unrelated',
1173
+ verificationState: 'assistant_inferred',
1174
+ scopeId: 'scope-conflicts-unrelated',
1175
+ firstSeenAt: now - 9_000,
1176
+ lastSeenAt: now - 4_000,
1177
+ validFrom: now - 9_000,
1178
+ invalidAt: null,
1179
+ },
1180
+ ]).run();
1181
+
1182
+ const conflict = createOrUpdatePendingConflict({
1183
+ scopeId: 'scope-conflicts-unrelated',
1184
+ existingItemId: 'item-conflict-existing-unrelated',
1185
+ candidateItemId: 'item-conflict-candidate-unrelated',
1186
+ relationship: 'ambiguous_contradiction',
1187
+ });
1188
+ db.update(memoryItemConflicts)
1189
+ .set({ createdAt: now, updatedAt: now, lastAskedAt: null })
1190
+ .where(eq(memoryItemConflicts.id, conflict.id))
1191
+ .run();
1192
+
1193
+ enqueueResolvePendingConflictsForMessageJob('msg-conflicts-unrelated', 'scope-conflicts-unrelated');
1194
+ const processed = await runMemoryJobsOnce();
1195
+ expect(processed).toBe(1);
1196
+
1197
+ const existing = db
1198
+ .select()
1199
+ .from(memoryItems)
1200
+ .where(eq(memoryItems.id, 'item-conflict-existing-unrelated'))
1201
+ .get();
1202
+ const candidate = db
1203
+ .select()
1204
+ .from(memoryItems)
1205
+ .where(eq(memoryItems.id, 'item-conflict-candidate-unrelated'))
1206
+ .get();
1207
+ const updatedConflict = getConflictById(conflict.id);
1208
+
1209
+ expect(existing?.status).toBe('active');
1210
+ expect(existing?.invalidAt).toBeNull();
1211
+ expect(candidate?.status).toBe('pending_clarification');
1212
+ expect(updatedConflict?.status).toBe('pending_clarification');
1213
+ expect(updatedConflict?.resolutionNote).toBeNull();
1214
+ } finally {
1215
+ TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1216
+ }
1217
+ });
1218
+
1121
1219
  test('cleanup job enqueue is deduped and retention overrides upgrade payload', () => {
1122
1220
  const db = getDb();
1123
1221
 
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import {
3
+ registerPendingCallback,
4
+ consumeCallback,
5
+ consumeCallbackError,
6
+ clearAllCallbacks,
7
+ } from '../security/oauth-callback-registry.js';
8
+
9
+ afterEach(() => {
10
+ clearAllCallbacks();
11
+ });
12
+
13
+ describe('OAuth callback registry', () => {
14
+ test('registerPendingCallback + consumeCallback resolves with code', async () => {
15
+ const promise = new Promise<string>((resolve, reject) => {
16
+ registerPendingCallback('state-1', resolve, reject);
17
+ });
18
+
19
+ const consumed = consumeCallback('state-1', 'auth-code-123');
20
+ expect(consumed).toBe(true);
21
+
22
+ const code = await promise;
23
+ expect(code).toBe('auth-code-123');
24
+ });
25
+
26
+ test('consumeCallback with unknown state returns false', () => {
27
+ const consumed = consumeCallback('nonexistent', 'code');
28
+ expect(consumed).toBe(false);
29
+ });
30
+
31
+ test('consumeCallbackError rejects the pending callback', async () => {
32
+ const promise = new Promise<string>((resolve, reject) => {
33
+ registerPendingCallback('state-err', resolve, reject);
34
+ });
35
+
36
+ const consumed = consumeCallbackError('state-err', 'access_denied');
37
+ expect(consumed).toBe(true);
38
+
39
+ await expect(promise).rejects.toThrow('access_denied');
40
+ });
41
+
42
+ test('consumeCallbackError with unknown state returns false', () => {
43
+ const consumed = consumeCallbackError('nonexistent', 'some error');
44
+ expect(consumed).toBe(false);
45
+ });
46
+
47
+ test('duplicate consumeCallback returns false on second call', async () => {
48
+ const promise = new Promise<string>((resolve, reject) => {
49
+ registerPendingCallback('state-dup', resolve, reject);
50
+ });
51
+
52
+ const first = consumeCallback('state-dup', 'code-1');
53
+ expect(first).toBe(true);
54
+
55
+ const second = consumeCallback('state-dup', 'code-2');
56
+ expect(second).toBe(false);
57
+
58
+ const code = await promise;
59
+ expect(code).toBe('code-1');
60
+ });
61
+
62
+ test('TTL expiry rejects callback with timeout error', async () => {
63
+ const promise = new Promise<string>((resolve, reject) => {
64
+ registerPendingCallback('state-ttl', resolve, reject, 50);
65
+ });
66
+
67
+ // Wait for the TTL to expire
68
+ await new Promise((r) => setTimeout(r, 100));
69
+
70
+ await expect(promise).rejects.toThrow('OAuth callback timed out');
71
+
72
+ // After expiry, consume should return false
73
+ const consumed = consumeCallback('state-ttl', 'late-code');
74
+ expect(consumed).toBe(false);
75
+ });
76
+
77
+ test('clearAllCallbacks cleans up all pending entries', () => {
78
+ registerPendingCallback('s1', () => {}, () => {});
79
+ registerPendingCallback('s2', () => {}, () => {});
80
+ clearAllCallbacks();
81
+
82
+ expect(consumeCallback('s1', 'code')).toBe(false);
83
+ expect(consumeCallback('s2', 'code')).toBe(false);
84
+ });
85
+ });