vellum 0.2.7 → 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.
- package/bun.lock +2 -2
- package/package.json +2 -2
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
- package/src/__tests__/ipc-snapshot.test.ts +11 -0
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +206 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/calls/twilio-config.ts +17 -1
- package/src/calls/twilio-routes.ts +10 -2
- package/src/calls/twilio-webhook-urls.ts +18 -21
- package/src/config/defaults.ts +4 -0
- package/src/config/schema.ts +30 -2
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +51 -2
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +16 -1
- package/src/daemon/session-tool-setup.ts +7 -0
- package/src/inbound/public-ingress-urls.ts +106 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +346 -149
- package/src/memory/runs-store.ts +0 -3
- package/src/memory/schema.ts +0 -4
- package/src/runtime/http-server.ts +84 -2
- package/src/security/oauth-callback-registry.ts +56 -0
- package/src/security/oauth2.ts +174 -58
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks — must be set up before importing the module under test
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
let mockPublicBaseUrl = '';
|
|
8
|
+
|
|
9
|
+
mock.module('../config/loader.js', () => ({
|
|
10
|
+
loadConfig: () => ({
|
|
11
|
+
ingress: { publicBaseUrl: mockPublicBaseUrl },
|
|
12
|
+
}),
|
|
13
|
+
getConfig: () => ({
|
|
14
|
+
ingress: { publicBaseUrl: mockPublicBaseUrl },
|
|
15
|
+
}),
|
|
16
|
+
loadRawConfig: () => ({}),
|
|
17
|
+
saveConfig: () => {},
|
|
18
|
+
invalidateConfigCache: () => {},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
mock.module('../util/logger.js', () => ({
|
|
22
|
+
getLogger: () => ({
|
|
23
|
+
info: () => {},
|
|
24
|
+
warn: () => {},
|
|
25
|
+
error: () => {},
|
|
26
|
+
debug: () => {},
|
|
27
|
+
trace: () => {},
|
|
28
|
+
fatal: () => {},
|
|
29
|
+
child: () => ({
|
|
30
|
+
info: () => {},
|
|
31
|
+
warn: () => {},
|
|
32
|
+
error: () => {},
|
|
33
|
+
debug: () => {},
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Track registerPendingCallback calls
|
|
39
|
+
let pendingCallbacks: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
|
|
40
|
+
|
|
41
|
+
mock.module('../security/oauth-callback-registry.js', () => ({
|
|
42
|
+
registerPendingCallback: (state: string, resolve: (code: string) => void, reject: (error: Error) => void) => {
|
|
43
|
+
pendingCallbacks.set(state, { resolve, reject });
|
|
44
|
+
},
|
|
45
|
+
consumeCallback: () => true,
|
|
46
|
+
consumeCallbackError: () => true,
|
|
47
|
+
clearAllCallbacks: () => { pendingCallbacks.clear(); },
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
let mockOAuthCallbackUrl = '';
|
|
51
|
+
|
|
52
|
+
mock.module('../inbound/public-ingress-urls.js', () => ({
|
|
53
|
+
getOAuthCallbackUrl: () => mockOAuthCallbackUrl,
|
|
54
|
+
getPublicBaseUrl: () => 'https://gw.example.com',
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// Mock fetch for token exchange
|
|
58
|
+
let mockTokenResponse: { ok: boolean; status: number; body: Record<string, unknown> } = {
|
|
59
|
+
ok: true,
|
|
60
|
+
status: 200,
|
|
61
|
+
body: {
|
|
62
|
+
access_token: 'test-access-token',
|
|
63
|
+
refresh_token: 'test-refresh-token',
|
|
64
|
+
expires_in: 3600,
|
|
65
|
+
scope: 'read write',
|
|
66
|
+
token_type: 'Bearer',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const originalFetch = globalThis.fetch;
|
|
71
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
72
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
73
|
+
if (url.includes('token')) {
|
|
74
|
+
if (!mockTokenResponse.ok) {
|
|
75
|
+
return new Response(JSON.stringify({ error: 'invalid_grant' }), {
|
|
76
|
+
status: mockTokenResponse.status,
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return new Response(JSON.stringify(mockTokenResponse.body), {
|
|
81
|
+
status: mockTokenResponse.status,
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return originalFetch(input, init);
|
|
86
|
+
}) as typeof fetch;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Import module under test AFTER mocks are in place
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
import { startOAuth2Flow, type OAuth2Config } from '../security/oauth2.js';
|
|
93
|
+
|
|
94
|
+
const BASE_OAUTH_CONFIG: OAuth2Config = {
|
|
95
|
+
authUrl: 'https://provider.example.com/authorize',
|
|
96
|
+
tokenUrl: 'https://provider.example.com/token',
|
|
97
|
+
scopes: ['read', 'write'],
|
|
98
|
+
clientId: 'test-client-id',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
mockPublicBaseUrl = '';
|
|
103
|
+
mockOAuthCallbackUrl = 'https://gw.example.com/webhooks/oauth/callback';
|
|
104
|
+
pendingCallbacks.clear();
|
|
105
|
+
mockTokenResponse = {
|
|
106
|
+
ok: true,
|
|
107
|
+
status: 200,
|
|
108
|
+
body: {
|
|
109
|
+
access_token: 'test-access-token',
|
|
110
|
+
refresh_token: 'test-refresh-token',
|
|
111
|
+
expires_in: 3600,
|
|
112
|
+
scope: 'read write',
|
|
113
|
+
token_type: 'Bearer',
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Tests
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('OAuth2 gateway transport', () => {
|
|
123
|
+
describe('auto-detection', () => {
|
|
124
|
+
test('selects gateway transport when ingress.publicBaseUrl is configured', async () => {
|
|
125
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
126
|
+
|
|
127
|
+
let capturedAuthUrl = '';
|
|
128
|
+
const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
|
|
129
|
+
openUrl: (url) => { capturedAuthUrl = url; },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Give the flow a tick to register the callback and open the browser
|
|
133
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
134
|
+
|
|
135
|
+
// The auth URL should contain the gateway redirect_uri, not a loopback one
|
|
136
|
+
expect(capturedAuthUrl).toContain('redirect_uri=');
|
|
137
|
+
expect(capturedAuthUrl).not.toContain('127.0.0.1');
|
|
138
|
+
expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
|
|
139
|
+
|
|
140
|
+
// Resolve the pending callback to complete the flow
|
|
141
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
142
|
+
expect(entries.length).toBe(1);
|
|
143
|
+
const [, { resolve }] = entries[0];
|
|
144
|
+
resolve('auth-code-from-gateway');
|
|
145
|
+
|
|
146
|
+
const result = await flowPromise;
|
|
147
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('selects loopback transport when ingress.publicBaseUrl is empty', async () => {
|
|
151
|
+
mockPublicBaseUrl = '';
|
|
152
|
+
|
|
153
|
+
let capturedAuthUrl = '';
|
|
154
|
+
const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
|
|
155
|
+
openUrl: (url) => { capturedAuthUrl = url; },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Give the flow a tick
|
|
159
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
160
|
+
|
|
161
|
+
// The auth URL should contain a loopback redirect_uri
|
|
162
|
+
expect(capturedAuthUrl).toContain('redirect_uri=');
|
|
163
|
+
expect(capturedAuthUrl).toContain('127.0.0.1');
|
|
164
|
+
|
|
165
|
+
// Extract the redirect_uri to send the callback
|
|
166
|
+
const authUrlParsed = new URL(capturedAuthUrl);
|
|
167
|
+
const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
|
|
168
|
+
const stateParam = authUrlParsed.searchParams.get('state')!;
|
|
169
|
+
|
|
170
|
+
// Simulate the OAuth provider callback to the loopback server
|
|
171
|
+
const callbackUrl = `${redirectUri}?code=loopback-code&state=${stateParam}`;
|
|
172
|
+
await fetch(callbackUrl);
|
|
173
|
+
|
|
174
|
+
const result = await flowPromise;
|
|
175
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('explicit transport', () => {
|
|
180
|
+
test('uses gateway transport when explicitly specified', async () => {
|
|
181
|
+
// Even with no publicBaseUrl, explicit gateway should work
|
|
182
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
183
|
+
|
|
184
|
+
let capturedAuthUrl = '';
|
|
185
|
+
const flowPromise = startOAuth2Flow(
|
|
186
|
+
BASE_OAUTH_CONFIG,
|
|
187
|
+
{ openUrl: (url) => { capturedAuthUrl = url; } },
|
|
188
|
+
{ callbackTransport: 'gateway' },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
192
|
+
|
|
193
|
+
expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
|
|
194
|
+
|
|
195
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
196
|
+
expect(entries.length).toBe(1);
|
|
197
|
+
entries[0][1].resolve('explicit-gateway-code');
|
|
198
|
+
|
|
199
|
+
const result = await flowPromise;
|
|
200
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('uses loopback transport when explicitly specified', async () => {
|
|
204
|
+
// Even with publicBaseUrl configured, explicit loopback should work
|
|
205
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
206
|
+
|
|
207
|
+
let capturedAuthUrl = '';
|
|
208
|
+
const flowPromise = startOAuth2Flow(
|
|
209
|
+
BASE_OAUTH_CONFIG,
|
|
210
|
+
{ openUrl: (url) => { capturedAuthUrl = url; } },
|
|
211
|
+
{ callbackTransport: 'loopback' },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
215
|
+
|
|
216
|
+
expect(capturedAuthUrl).toContain('127.0.0.1');
|
|
217
|
+
|
|
218
|
+
const authUrlParsed = new URL(capturedAuthUrl);
|
|
219
|
+
const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
|
|
220
|
+
const stateParam = authUrlParsed.searchParams.get('state')!;
|
|
221
|
+
|
|
222
|
+
await fetch(`${redirectUri}?code=loopback-code&state=${stateParam}`);
|
|
223
|
+
|
|
224
|
+
const result = await flowPromise;
|
|
225
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('gateway transport flow', () => {
|
|
230
|
+
test('success: register callback, consume with code, exchange for tokens', async () => {
|
|
231
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
232
|
+
|
|
233
|
+
const flowPromise = startOAuth2Flow(
|
|
234
|
+
BASE_OAUTH_CONFIG,
|
|
235
|
+
{ openUrl: () => {} },
|
|
236
|
+
{ callbackTransport: 'gateway' },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
240
|
+
|
|
241
|
+
// A callback should be registered
|
|
242
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
243
|
+
expect(entries.length).toBe(1);
|
|
244
|
+
|
|
245
|
+
// Simulate gateway delivering the authorization code
|
|
246
|
+
const [state, { resolve }] = entries[0];
|
|
247
|
+
expect(typeof state).toBe('string');
|
|
248
|
+
expect(state.length).toBeGreaterThan(0);
|
|
249
|
+
|
|
250
|
+
resolve('gateway-auth-code');
|
|
251
|
+
|
|
252
|
+
const result = await flowPromise;
|
|
253
|
+
expect(result.tokens.accessToken).toBe('test-access-token');
|
|
254
|
+
expect(result.tokens.refreshToken).toBe('test-refresh-token');
|
|
255
|
+
expect(result.tokens.expiresIn).toBe(3600);
|
|
256
|
+
expect(result.grantedScopes).toEqual(['read', 'write']);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('error: register callback, consume with error, rejects', async () => {
|
|
260
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
261
|
+
|
|
262
|
+
const flowPromise = startOAuth2Flow(
|
|
263
|
+
BASE_OAUTH_CONFIG,
|
|
264
|
+
{ openUrl: () => {} },
|
|
265
|
+
{ callbackTransport: 'gateway' },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
269
|
+
|
|
270
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
271
|
+
expect(entries.length).toBe(1);
|
|
272
|
+
|
|
273
|
+
// Simulate the gateway delivering an error (e.g. user denied access)
|
|
274
|
+
const [, { reject }] = entries[0];
|
|
275
|
+
reject(new Error('OAuth2 authorization denied: access_denied'));
|
|
276
|
+
|
|
277
|
+
await expect(flowPromise).rejects.toThrow('OAuth2 authorization denied: access_denied');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('token exchange failure propagates error', async () => {
|
|
281
|
+
mockPublicBaseUrl = 'https://gw.example.com';
|
|
282
|
+
mockTokenResponse = { ok: false, status: 400, body: { error: 'invalid_grant' } };
|
|
283
|
+
|
|
284
|
+
const flowPromise = startOAuth2Flow(
|
|
285
|
+
BASE_OAUTH_CONFIG,
|
|
286
|
+
{ openUrl: () => {} },
|
|
287
|
+
{ callbackTransport: 'gateway' },
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
291
|
+
|
|
292
|
+
const entries = Array.from(pendingCallbacks.entries());
|
|
293
|
+
entries[0][1].resolve('code-that-fails-exchange');
|
|
294
|
+
|
|
295
|
+
await expect(flowPromise).rejects.toThrow('OAuth2 token exchange failed');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -262,34 +262,51 @@ describe('ProviderCommitMessageGenerator', () => {
|
|
|
262
262
|
expect(result.reason).toBe('invalid_output');
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
// 11. LLM
|
|
266
|
-
test('LLM
|
|
267
|
-
const longSubject = 'a'.repeat(
|
|
265
|
+
// 11. LLM subject > 72 chars → truncated to 72, still source "llm"
|
|
266
|
+
test('LLM subject > 72 chars → truncated to 72, source "llm"', async () => {
|
|
267
|
+
const longSubject = 'a'.repeat(100);
|
|
268
268
|
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject));
|
|
269
269
|
const gen = getCommitMessageGenerator();
|
|
270
270
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
271
271
|
changedFiles: baseContext.changedFiles,
|
|
272
272
|
});
|
|
273
|
-
expect(result.source).toBe('
|
|
274
|
-
expect(result.reason).
|
|
273
|
+
expect(result.source).toBe('llm');
|
|
274
|
+
expect(result.reason).toBeUndefined();
|
|
275
|
+
expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
|
|
276
|
+
expect(result.message).toBe('a'.repeat(72));
|
|
275
277
|
});
|
|
276
278
|
|
|
277
|
-
//
|
|
278
|
-
test('
|
|
279
|
+
// 11b. LLM subject > 72 chars with body → subject truncated, body preserved
|
|
280
|
+
test('LLM subject > 72 chars with body → subject truncated, body preserved', async () => {
|
|
281
|
+
const longSubject = 'b'.repeat(80);
|
|
282
|
+
const body = '\n\n- bullet one\n- bullet two';
|
|
283
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject + body));
|
|
284
|
+
const gen = getCommitMessageGenerator();
|
|
285
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
286
|
+
changedFiles: baseContext.changedFiles,
|
|
287
|
+
});
|
|
288
|
+
expect(result.source).toBe('llm');
|
|
289
|
+
expect(result.reason).toBeUndefined();
|
|
290
|
+
expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
|
|
291
|
+
expect(result.message).toBe('b'.repeat(72) + body);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// 12. Keyless provider (Ollama) without fast model → missing_fast_model (skips API key check)
|
|
295
|
+
test('Ollama without API key or fast model → returns deterministic, reason "missing_fast_model"', async () => {
|
|
279
296
|
(currentConfig as Record<string, unknown>).provider = 'ollama';
|
|
280
297
|
currentConfig.apiKeys = {} as Record<string, string>;
|
|
281
|
-
// Ollama has no default fast model, so it will fall through to provider_error,
|
|
282
|
-
// but crucially it should NOT be blocked by missing_provider_api_key
|
|
283
298
|
const gen = getCommitMessageGenerator();
|
|
284
299
|
const result = await gen.generateCommitMessage(baseContext, {
|
|
285
300
|
changedFiles: baseContext.changedFiles,
|
|
286
301
|
});
|
|
287
302
|
expect(result.source).toBe('deterministic');
|
|
303
|
+
expect(result.reason).toBe('missing_fast_model');
|
|
288
304
|
expect(result.reason).not.toBe('missing_provider_api_key');
|
|
305
|
+
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
289
306
|
});
|
|
290
307
|
|
|
291
|
-
// 13.
|
|
292
|
-
test('
|
|
308
|
+
// 13. Unknown provider without fast model default → missing_fast_model, no provider call
|
|
309
|
+
test('Unknown provider without fast model default → returns deterministic, reason "missing_fast_model"', async () => {
|
|
293
310
|
(currentConfig as Record<string, unknown>).provider = 'exotic-provider';
|
|
294
311
|
currentConfig.apiKeys = { 'exotic-provider': 'sk-exotic' } as Record<string, string>;
|
|
295
312
|
const gen = getCommitMessageGenerator();
|
|
@@ -297,7 +314,29 @@ describe('ProviderCommitMessageGenerator', () => {
|
|
|
297
314
|
changedFiles: baseContext.changedFiles,
|
|
298
315
|
});
|
|
299
316
|
expect(result.source).toBe('deterministic');
|
|
300
|
-
expect(result.reason).toBe('
|
|
317
|
+
expect(result.reason).toBe('missing_fast_model');
|
|
301
318
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
|
302
319
|
});
|
|
320
|
+
|
|
321
|
+
// 14. Fast-model override enables LLM path for provider without built-in default
|
|
322
|
+
test('fast-model override enables LLM path for provider without built-in default', async () => {
|
|
323
|
+
(currentConfig as Record<string, unknown>).provider = 'ollama';
|
|
324
|
+
currentConfig.apiKeys = {} as Record<string, string>; // Ollama is keyless
|
|
325
|
+
currentConfig.workspaceGit.commitMessageLLM.providerFastModelOverrides = {
|
|
326
|
+
ollama: 'llama3.2:3b',
|
|
327
|
+
};
|
|
328
|
+
const commitMsg = 'fix: local model commit';
|
|
329
|
+
mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(commitMsg));
|
|
330
|
+
const gen = getCommitMessageGenerator();
|
|
331
|
+
const result = await gen.generateCommitMessage(baseContext, {
|
|
332
|
+
changedFiles: baseContext.changedFiles,
|
|
333
|
+
});
|
|
334
|
+
expect(result.source).toBe('llm');
|
|
335
|
+
expect(result.message).toBe(commitMsg);
|
|
336
|
+
|
|
337
|
+
// Verify the override model was passed
|
|
338
|
+
const callArgs = mockSendMessage.mock.calls[0];
|
|
339
|
+
const options = callArgs[3] as { config: { model: string } };
|
|
340
|
+
expect(options.config.model).toBe('llama3.2:3b');
|
|
341
|
+
});
|
|
303
342
|
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks — silence logger output during tests
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
function makeLoggerStub(): Record<string, unknown> {
|
|
8
|
+
const stub: Record<string, unknown> = {};
|
|
9
|
+
for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
|
|
10
|
+
stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
|
|
11
|
+
}
|
|
12
|
+
return stub;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
mock.module('../util/logger.js', () => ({
|
|
16
|
+
getLogger: () => makeLoggerStub(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
getPublicBaseUrl,
|
|
21
|
+
getTwilioVoiceWebhookUrl,
|
|
22
|
+
getTwilioStatusCallbackUrl,
|
|
23
|
+
getTwilioConnectActionUrl,
|
|
24
|
+
getTwilioRelayUrl,
|
|
25
|
+
getOAuthCallbackUrl,
|
|
26
|
+
} from '../inbound/public-ingress-urls.js';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// getPublicBaseUrl — fallback chain
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
describe('getPublicBaseUrl', () => {
|
|
33
|
+
let savedEnv: string | undefined;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
savedEnv = process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
37
|
+
delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
if (savedEnv !== undefined) {
|
|
42
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = savedEnv;
|
|
43
|
+
} else {
|
|
44
|
+
delete process.env.TWILIO_WEBHOOK_BASE_URL;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('prefers ingress.publicBaseUrl when set', () => {
|
|
49
|
+
const result = getPublicBaseUrl({
|
|
50
|
+
ingress: { publicBaseUrl: 'https://ingress.example.com/' },
|
|
51
|
+
calls: { webhookBaseUrl: 'https://calls.example.com' },
|
|
52
|
+
});
|
|
53
|
+
expect(result).toBe('https://ingress.example.com');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('falls back to calls.webhookBaseUrl when ingress.publicBaseUrl is empty', () => {
|
|
57
|
+
const result = getPublicBaseUrl({
|
|
58
|
+
ingress: { publicBaseUrl: '' },
|
|
59
|
+
calls: { webhookBaseUrl: 'https://calls.example.com/' },
|
|
60
|
+
});
|
|
61
|
+
expect(result).toBe('https://calls.example.com');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('falls back to calls.webhookBaseUrl when ingress is undefined', () => {
|
|
65
|
+
const result = getPublicBaseUrl({
|
|
66
|
+
calls: { webhookBaseUrl: 'https://calls.example.com' },
|
|
67
|
+
});
|
|
68
|
+
expect(result).toBe('https://calls.example.com');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('falls back to TWILIO_WEBHOOK_BASE_URL env var when both config fields are empty', () => {
|
|
72
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com/';
|
|
73
|
+
const result = getPublicBaseUrl({
|
|
74
|
+
ingress: { publicBaseUrl: '' },
|
|
75
|
+
calls: { webhookBaseUrl: '' },
|
|
76
|
+
});
|
|
77
|
+
expect(result).toBe('https://env.example.com');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('falls back to env var when config fields are undefined', () => {
|
|
81
|
+
process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
|
|
82
|
+
const result = getPublicBaseUrl({});
|
|
83
|
+
expect(result).toBe('https://env.example.com');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('throws when no source provides a value', () => {
|
|
87
|
+
expect(() => getPublicBaseUrl({
|
|
88
|
+
ingress: { publicBaseUrl: '' },
|
|
89
|
+
calls: { webhookBaseUrl: '' },
|
|
90
|
+
})).toThrow(/No public base URL configured/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('throws when all sources are undefined', () => {
|
|
94
|
+
expect(() => getPublicBaseUrl({})).toThrow(/No public base URL configured/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('normalizes trailing slashes from ingress.publicBaseUrl', () => {
|
|
98
|
+
const result = getPublicBaseUrl({
|
|
99
|
+
ingress: { publicBaseUrl: 'https://example.com///' },
|
|
100
|
+
});
|
|
101
|
+
expect(result).toBe('https://example.com');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('trims whitespace from ingress.publicBaseUrl', () => {
|
|
105
|
+
const result = getPublicBaseUrl({
|
|
106
|
+
ingress: { publicBaseUrl: ' https://example.com ' },
|
|
107
|
+
});
|
|
108
|
+
expect(result).toBe('https://example.com');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('skips whitespace-only ingress.publicBaseUrl and falls through', () => {
|
|
112
|
+
const result = getPublicBaseUrl({
|
|
113
|
+
ingress: { publicBaseUrl: ' ' },
|
|
114
|
+
calls: { webhookBaseUrl: 'https://calls.example.com' },
|
|
115
|
+
});
|
|
116
|
+
expect(result).toBe('https://calls.example.com');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// getTwilioVoiceWebhookUrl
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
describe('getTwilioVoiceWebhookUrl', () => {
|
|
125
|
+
test('builds correct URL with callSessionId', () => {
|
|
126
|
+
const url = getTwilioVoiceWebhookUrl(
|
|
127
|
+
{ ingress: { publicBaseUrl: 'https://example.com' } },
|
|
128
|
+
'session-123',
|
|
129
|
+
);
|
|
130
|
+
expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=session-123');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('normalizes base URL before composing', () => {
|
|
134
|
+
const url = getTwilioVoiceWebhookUrl(
|
|
135
|
+
{ ingress: { publicBaseUrl: 'https://example.com/' } },
|
|
136
|
+
'abc',
|
|
137
|
+
);
|
|
138
|
+
expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=abc');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// getTwilioStatusCallbackUrl
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
describe('getTwilioStatusCallbackUrl', () => {
|
|
147
|
+
test('builds correct URL', () => {
|
|
148
|
+
const url = getTwilioStatusCallbackUrl({
|
|
149
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
150
|
+
});
|
|
151
|
+
expect(url).toBe('https://example.com/webhooks/twilio/status');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// getTwilioConnectActionUrl
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
describe('getTwilioConnectActionUrl', () => {
|
|
160
|
+
test('builds correct URL', () => {
|
|
161
|
+
const url = getTwilioConnectActionUrl({
|
|
162
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
163
|
+
});
|
|
164
|
+
expect(url).toBe('https://example.com/webhooks/twilio/connect-action');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// getTwilioRelayUrl — scheme conversion
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
describe('getTwilioRelayUrl', () => {
|
|
173
|
+
test('converts https to wss', () => {
|
|
174
|
+
const url = getTwilioRelayUrl({
|
|
175
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
176
|
+
});
|
|
177
|
+
expect(url).toBe('wss://example.com/webhooks/twilio/relay');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('converts http to ws', () => {
|
|
181
|
+
const url = getTwilioRelayUrl({
|
|
182
|
+
ingress: { publicBaseUrl: 'http://localhost:7821' },
|
|
183
|
+
});
|
|
184
|
+
expect(url).toBe('ws://localhost:7821/webhooks/twilio/relay');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('normalizes trailing slash before conversion', () => {
|
|
188
|
+
const url = getTwilioRelayUrl({
|
|
189
|
+
ingress: { publicBaseUrl: 'https://example.com/' },
|
|
190
|
+
});
|
|
191
|
+
expect(url).toBe('wss://example.com/webhooks/twilio/relay');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// getOAuthCallbackUrl
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe('getOAuthCallbackUrl', () => {
|
|
200
|
+
test('builds correct URL', () => {
|
|
201
|
+
const url = getOAuthCallbackUrl({
|
|
202
|
+
ingress: { publicBaseUrl: 'https://example.com' },
|
|
203
|
+
});
|
|
204
|
+
expect(url).toBe('https://example.com/webhooks/oauth/callback');
|
|
205
|
+
});
|
|
206
|
+
});
|