vellum 0.2.7 → 0.2.9

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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -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 +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Tests for gateway-only ingress mode enforcement in the runtime HTTP server.
3
+ *
4
+ * Verifies:
5
+ * - Direct Twilio webhook routes return 410 in gateway_only mode
6
+ * - Internal forwarding routes (gateway→runtime) still work in gateway_only mode
7
+ * - Relay WebSocket upgrade blocked for non-private-network origins (isPrivateNetworkOrigin) in gateway_only mode
8
+ * - Relay WebSocket upgrade allowed from private network peers/origins in gateway_only mode
9
+ * - All routes work normally in compat mode
10
+ * - Startup warning when RUNTIME_HTTP_HOST is not loopback in gateway_only mode
11
+ */
12
+ import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
13
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'gw-only-enforcement-test-')));
18
+
19
+ mock.module('../util/platform.js', () => ({
20
+ getRootDir: () => testDir,
21
+ getDataDir: () => testDir,
22
+ getWorkspaceConfigPath: () => join(testDir, 'config.json'),
23
+ isMacOS: () => process.platform === 'darwin',
24
+ isLinux: () => process.platform === 'linux',
25
+ isWindows: () => process.platform === 'win32',
26
+ getSocketPath: () => join(testDir, 'test.sock'),
27
+ getPidPath: () => join(testDir, 'test.pid'),
28
+ getDbPath: () => join(testDir, 'test.db'),
29
+ getLogPath: () => join(testDir, 'test.log'),
30
+ ensureDataDir: () => {},
31
+ migrateToDataLayout: () => {},
32
+ migrateToWorkspaceLayout: () => {},
33
+ }));
34
+
35
+ // Configurable ingress mode — tests toggle this between 'gateway_only' and 'compat'
36
+ let mockIngressMode: 'gateway_only' | 'compat' = 'compat';
37
+
38
+ const logMessages: { level: string; msg: string; args?: unknown }[] = [];
39
+
40
+ mock.module('../util/logger.js', () => ({
41
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
42
+ get: (_target, prop: string) => {
43
+ if (prop === 'child') return () => new Proxy({} as Record<string, unknown>, {
44
+ get: () => () => {},
45
+ });
46
+ return (...args: unknown[]) => {
47
+ if (typeof args[0] === 'string') {
48
+ logMessages.push({ level: prop, msg: args[0] });
49
+ } else if (typeof args[1] === 'string') {
50
+ logMessages.push({ level: prop, msg: args[1], args: args[0] });
51
+ }
52
+ };
53
+ },
54
+ }),
55
+ }));
56
+
57
+ mock.module('../config/loader.js', () => ({
58
+ loadConfig: () => ({
59
+ model: 'test',
60
+ provider: 'test',
61
+ apiKeys: {},
62
+ memory: { enabled: false },
63
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
64
+ secretDetection: { enabled: false },
65
+ calls: {
66
+ enabled: true,
67
+ provider: 'twilio',
68
+ webhookBaseUrl: 'https://test.example.com',
69
+ maxDurationSeconds: 3600,
70
+ userConsultTimeoutSeconds: 120,
71
+ disclosure: { enabled: false, text: '' },
72
+ safety: { denyCategories: [] },
73
+ },
74
+ ingress: {
75
+ publicBaseUrl: 'https://test.example.com',
76
+ mode: mockIngressMode,
77
+ },
78
+ }),
79
+ getConfig: () => ({
80
+ model: 'test',
81
+ provider: 'test',
82
+ apiKeys: {},
83
+ memory: { enabled: false },
84
+ rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
85
+ secretDetection: { enabled: false },
86
+ ingress: {
87
+ publicBaseUrl: 'https://test.example.com',
88
+ mode: mockIngressMode,
89
+ },
90
+ }),
91
+ invalidateConfigCache: () => {},
92
+ }));
93
+
94
+ // Mock Twilio provider
95
+ mock.module('../calls/twilio-provider.js', () => ({
96
+ TwilioConversationRelayProvider: class {
97
+ static getAuthToken() { return 'mock-auth-token'; }
98
+ static verifyWebhookSignature() { return true; }
99
+ async initiateCall() { return { callSid: 'CA_mock_sid' }; }
100
+ async endCall() { return; }
101
+ },
102
+ }));
103
+
104
+ // Mock Twilio config
105
+ mock.module('../calls/twilio-config.js', () => ({
106
+ getTwilioConfig: () => ({
107
+ accountSid: 'AC_test',
108
+ authToken: 'test_token',
109
+ phoneNumber: '+15550001111',
110
+ webhookBaseUrl: 'https://test.example.com',
111
+ wssBaseUrl: 'wss://test.example.com',
112
+ }),
113
+ }));
114
+
115
+ mock.module('../security/secure-keys.js', () => ({
116
+ getSecureKey: () => null,
117
+ setSecureKey: () => true,
118
+ deleteSecureKey: () => {},
119
+ }));
120
+
121
+ mock.module('../inbound/public-ingress-urls.js', () => ({
122
+ getPublicBaseUrl: () => 'https://test.example.com',
123
+ getTwilioRelayUrl: () => 'wss://test.example.com/webhooks/twilio/relay',
124
+ getTwilioVoiceWebhookUrl: (_cfg: unknown, id: string) => `https://test.example.com/webhooks/twilio/voice?callSessionId=${id}`,
125
+ getTwilioStatusCallbackUrl: () => 'https://test.example.com/webhooks/twilio/status',
126
+ getTwilioConnectActionUrl: () => 'https://test.example.com/webhooks/twilio/connect-action',
127
+ getOAuthCallbackUrl: () => 'https://test.example.com/webhooks/oauth/callback',
128
+ }));
129
+
130
+ // Mock the oauth callback registry
131
+ mock.module('../security/oauth-callback-registry.js', () => ({
132
+ consumeCallback: () => true,
133
+ consumeCallbackError: () => true,
134
+ }));
135
+
136
+ import { RuntimeHttpServer, isPrivateAddress } from '../runtime/http-server.js';
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Helpers
140
+ // ---------------------------------------------------------------------------
141
+
142
+ const TEST_TOKEN = 'test-bearer-token-gw';
143
+ const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
144
+
145
+ function makeFormBody(params: Record<string, string>): string {
146
+ return new URLSearchParams(params).toString();
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Tests
151
+ // ---------------------------------------------------------------------------
152
+
153
+ describe('gateway-only ingress enforcement', () => {
154
+ let server: RuntimeHttpServer;
155
+ let port: number;
156
+
157
+ beforeEach(async () => {
158
+ logMessages.length = 0;
159
+ server = new RuntimeHttpServer({
160
+ port: 0,
161
+ hostname: '127.0.0.1',
162
+ bearerToken: TEST_TOKEN,
163
+ });
164
+ await server.start();
165
+ port = server.actualPort;
166
+ });
167
+
168
+ afterEach(async () => {
169
+ await server.stop();
170
+ });
171
+
172
+ // ── Direct Twilio webhook routes blocked in gateway_only mode ──────
173
+
174
+ describe('gateway_only mode — direct webhook routes', () => {
175
+ beforeEach(() => { mockIngressMode = 'gateway_only'; });
176
+ afterEach(() => { mockIngressMode = 'compat'; });
177
+
178
+ test('POST /webhooks/twilio/voice returns 410', async () => {
179
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice`, {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
182
+ body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
183
+ });
184
+ expect(res.status).toBe(410);
185
+ const body = await res.json() as { error: string; code: string };
186
+ expect(body.code).toBe('GATEWAY_ONLY');
187
+ expect(body.error).toContain('gateway-only mode');
188
+ });
189
+
190
+ test('POST /webhooks/twilio/status returns 410', async () => {
191
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
194
+ body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
195
+ });
196
+ expect(res.status).toBe(410);
197
+ const body = await res.json() as { error: string; code: string };
198
+ expect(body.code).toBe('GATEWAY_ONLY');
199
+ });
200
+
201
+ test('POST /webhooks/twilio/connect-action returns 410', async () => {
202
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/connect-action`, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
205
+ body: makeFormBody({ CallSid: 'CA123' }),
206
+ });
207
+ expect(res.status).toBe(410);
208
+ const body = await res.json() as { error: string; code: string };
209
+ expect(body.code).toBe('GATEWAY_ONLY');
210
+ });
211
+
212
+ test('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
213
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook`, {
214
+ method: 'POST',
215
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
216
+ body: makeFormBody({ CallSid: 'CA123' }),
217
+ });
218
+ expect(res.status).toBe(410);
219
+ const body = await res.json() as { error: string; code: string };
220
+ expect(body.code).toBe('GATEWAY_ONLY');
221
+ });
222
+
223
+ test('POST /v1/calls/twilio/status returns 410', async () => {
224
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/status`, {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
227
+ body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
228
+ });
229
+ expect(res.status).toBe(410);
230
+ const body = await res.json() as { error: string; code: string };
231
+ expect(body.code).toBe('GATEWAY_ONLY');
232
+ });
233
+ });
234
+
235
+ // ── Internal forwarding routes still work in gateway_only mode ─────
236
+
237
+ describe('gateway_only mode — internal forwarding routes', () => {
238
+ beforeEach(() => { mockIngressMode = 'gateway_only'; });
239
+ afterEach(() => { mockIngressMode = 'compat'; });
240
+
241
+ test('POST /v1/internal/twilio/voice-webhook is NOT blocked', async () => {
242
+ const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook`, {
243
+ method: 'POST',
244
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
245
+ body: JSON.stringify({
246
+ params: { CallSid: 'CA123', AccountSid: 'AC_test' },
247
+ originalUrl: `http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook?callSessionId=sess-123`,
248
+ }),
249
+ });
250
+ // Should NOT be 410 — it may 404 or 400 because the call session
251
+ // doesn't exist, but the gateway-only guard should NOT block it.
252
+ expect(res.status).not.toBe(410);
253
+ });
254
+
255
+ test('POST /v1/internal/twilio/status is NOT blocked', async () => {
256
+ const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/status`, {
257
+ method: 'POST',
258
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({
260
+ params: { CallSid: 'CA123', CallStatus: 'completed' },
261
+ }),
262
+ });
263
+ expect(res.status).not.toBe(410);
264
+ });
265
+
266
+ test('POST /v1/internal/twilio/connect-action is NOT blocked', async () => {
267
+ const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/connect-action`, {
268
+ method: 'POST',
269
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
270
+ body: JSON.stringify({
271
+ params: { CallSid: 'CA123' },
272
+ }),
273
+ });
274
+ expect(res.status).not.toBe(410);
275
+ });
276
+
277
+ test('POST /v1/internal/oauth/callback is NOT blocked', async () => {
278
+ const res = await fetch(`http://127.0.0.1:${port}/v1/internal/oauth/callback`, {
279
+ method: 'POST',
280
+ headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({
282
+ state: 'test-state',
283
+ code: 'test-code',
284
+ }),
285
+ });
286
+ // Should succeed or return a non-410 status
287
+ expect(res.status).not.toBe(410);
288
+ });
289
+ });
290
+
291
+ // ── Relay WebSocket upgrade in gateway_only mode ───────────────────
292
+
293
+ describe('gateway_only mode — relay WebSocket upgrade', () => {
294
+ beforeEach(() => { mockIngressMode = 'gateway_only'; });
295
+ afterEach(() => { mockIngressMode = 'compat'; });
296
+
297
+ test('blocks non-private-network origin', async () => {
298
+ // The peer address (127.0.0.1) passes the private network check,
299
+ // but the external Origin header triggers the secondary defense-in-depth block.
300
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
301
+ headers: {
302
+ 'Upgrade': 'websocket',
303
+ 'Connection': 'Upgrade',
304
+ 'Origin': 'https://external.example.com',
305
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
306
+ 'Sec-WebSocket-Version': '13',
307
+ },
308
+ });
309
+ expect(res.status).toBe(403);
310
+ const body = await res.json() as { error: string; code: string };
311
+ expect(body.code).toBe('GATEWAY_ONLY');
312
+ expect(body.error).toContain('gateway-only mode');
313
+ });
314
+
315
+ test('allows request with no origin header (private network peer)', async () => {
316
+ // Without an origin header, isPrivateNetworkOrigin returns true.
317
+ // The peer address (127.0.0.1) passes the private network peer check.
318
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
319
+ headers: {
320
+ 'Upgrade': 'websocket',
321
+ 'Connection': 'Upgrade',
322
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
323
+ 'Sec-WebSocket-Version': '13',
324
+ },
325
+ });
326
+ // Should NOT be 403 — WebSocket upgrade may or may not succeed
327
+ // depending on test environment, but the gateway guard should pass.
328
+ expect(res.status).not.toBe(403);
329
+ });
330
+
331
+ test('allows localhost origin from loopback peer', async () => {
332
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
333
+ headers: {
334
+ 'Upgrade': 'websocket',
335
+ 'Connection': 'Upgrade',
336
+ 'Origin': 'http://127.0.0.1:3000',
337
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
338
+ 'Sec-WebSocket-Version': '13',
339
+ },
340
+ });
341
+ // Should NOT be 403
342
+ expect(res.status).not.toBe(403);
343
+ });
344
+ });
345
+
346
+ // ── Compat mode — everything works as before ───────────────────────
347
+
348
+ describe('compat mode — no enforcement', () => {
349
+ beforeEach(() => { mockIngressMode = 'compat'; });
350
+
351
+ test('POST /webhooks/twilio/voice is NOT blocked', async () => {
352
+ // In compat mode, disable webhook validation to focus on the ingress check
353
+ const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
354
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
355
+ try {
356
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice?callSessionId=test-compat`, {
357
+ method: 'POST',
358
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
359
+ body: makeFormBody({ CallSid: 'CA_compat', AccountSid: 'AC_test' }),
360
+ });
361
+ // Should NOT be 410 (gateway-only)
362
+ expect(res.status).not.toBe(410);
363
+ } finally {
364
+ if (savedDisable !== undefined) {
365
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
366
+ } else {
367
+ delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
368
+ }
369
+ }
370
+ });
371
+
372
+ test('POST /webhooks/twilio/status is NOT blocked', async () => {
373
+ const savedDisable = process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
374
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
375
+ try {
376
+ const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
377
+ method: 'POST',
378
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
379
+ body: makeFormBody({ CallSid: 'CA_compat', CallStatus: 'completed' }),
380
+ });
381
+ expect(res.status).not.toBe(410);
382
+ } finally {
383
+ if (savedDisable !== undefined) {
384
+ process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = savedDisable;
385
+ } else {
386
+ delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
387
+ }
388
+ }
389
+ });
390
+
391
+ test('relay WebSocket upgrade is NOT blocked for external origin', async () => {
392
+ const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-compat`, {
393
+ headers: {
394
+ 'Upgrade': 'websocket',
395
+ 'Connection': 'Upgrade',
396
+ 'Origin': 'https://external.example.com',
397
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
398
+ 'Sec-WebSocket-Version': '13',
399
+ },
400
+ });
401
+ // In compat mode, the gateway-only guard should not activate
402
+ expect(res.status).not.toBe(403);
403
+ });
404
+ });
405
+
406
+ // ── isPrivateAddress unit tests ─────────────────────────────────────
407
+
408
+ describe('isPrivateAddress', () => {
409
+ // Loopback
410
+ test.each([
411
+ '127.0.0.1',
412
+ '127.0.0.2',
413
+ '127.255.255.255',
414
+ '::1',
415
+ '::ffff:127.0.0.1',
416
+ ])('accepts loopback address %s', (addr) => {
417
+ expect(isPrivateAddress(addr)).toBe(true);
418
+ });
419
+
420
+ // RFC 1918 private ranges
421
+ test.each([
422
+ '10.0.0.1',
423
+ '10.255.255.255',
424
+ '172.16.0.1',
425
+ '172.31.255.255',
426
+ '192.168.0.1',
427
+ '192.168.1.100',
428
+ ])('accepts RFC 1918 private address %s', (addr) => {
429
+ expect(isPrivateAddress(addr)).toBe(true);
430
+ });
431
+
432
+ // Link-local
433
+ test.each([
434
+ '169.254.0.1',
435
+ '169.254.255.255',
436
+ ])('accepts link-local address %s', (addr) => {
437
+ expect(isPrivateAddress(addr)).toBe(true);
438
+ });
439
+
440
+ // IPv6 unique local (fc00::/7)
441
+ test.each([
442
+ 'fc00::1',
443
+ 'fd12:3456:789a::1',
444
+ 'fdff::1',
445
+ ])('accepts IPv6 unique local address %s', (addr) => {
446
+ expect(isPrivateAddress(addr)).toBe(true);
447
+ });
448
+
449
+ // IPv6 link-local (fe80::/10)
450
+ test.each([
451
+ 'fe80::1',
452
+ 'fe80::abcd:1234',
453
+ ])('accepts IPv6 link-local address %s', (addr) => {
454
+ expect(isPrivateAddress(addr)).toBe(true);
455
+ });
456
+
457
+ // IPv4-mapped IPv6 private addresses
458
+ test.each([
459
+ '::ffff:10.0.0.1',
460
+ '::ffff:172.16.0.1',
461
+ '::ffff:192.168.1.1',
462
+ '::ffff:169.254.0.1',
463
+ ])('accepts IPv4-mapped IPv6 private address %s', (addr) => {
464
+ expect(isPrivateAddress(addr)).toBe(true);
465
+ });
466
+
467
+ // Public addresses — should be rejected
468
+ test.each([
469
+ '8.8.8.8',
470
+ '1.1.1.1',
471
+ '203.0.113.1',
472
+ '172.32.0.1',
473
+ '172.15.255.255',
474
+ '11.0.0.1',
475
+ '192.169.0.1',
476
+ '::ffff:8.8.8.8',
477
+ '2001:db8::1',
478
+ ])('rejects public address %s', (addr) => {
479
+ expect(isPrivateAddress(addr)).toBe(false);
480
+ });
481
+ });
482
+
483
+ // ── Startup warning for non-loopback host ──────────────────────────
484
+
485
+ describe('startup guard — non-loopback host warning', () => {
486
+ test('logs warning when hostname is not loopback in gateway_only mode', async () => {
487
+ mockIngressMode = 'gateway_only';
488
+ logMessages.length = 0;
489
+
490
+ const warnServer = new RuntimeHttpServer({
491
+ port: 0,
492
+ hostname: '0.0.0.0',
493
+ bearerToken: TEST_TOKEN,
494
+ });
495
+ await warnServer.start();
496
+
497
+ const infoMsg = logMessages.find(
498
+ m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
499
+ );
500
+ expect(infoMsg).toBeDefined();
501
+
502
+ const warnMsg = logMessages.find(
503
+ m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
504
+ );
505
+ expect(warnMsg).toBeDefined();
506
+
507
+ await warnServer.stop();
508
+ mockIngressMode = 'compat';
509
+ });
510
+
511
+ test('does NOT log warning when hostname is loopback in gateway_only mode', async () => {
512
+ mockIngressMode = 'gateway_only';
513
+ logMessages.length = 0;
514
+
515
+ // The main test server already uses 127.0.0.1, so restart with
516
+ // a fresh server and capture logs
517
+ const loopbackServer = new RuntimeHttpServer({
518
+ port: 0,
519
+ hostname: '127.0.0.1',
520
+ bearerToken: TEST_TOKEN,
521
+ });
522
+ await loopbackServer.start();
523
+
524
+ const infoMsg = logMessages.find(
525
+ m => m.level === 'info' && m.msg.includes('gateway-only ingress mode'),
526
+ );
527
+ expect(infoMsg).toBeDefined();
528
+
529
+ const warnMsg = logMessages.find(
530
+ m => m.level === 'warn' && m.msg.includes('not bound to loopback'),
531
+ );
532
+ expect(warnMsg).toBeUndefined();
533
+
534
+ await loopbackServer.stop();
535
+ mockIngressMode = 'compat';
536
+ });
537
+ });
538
+ });