vellum 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,581 @@
1
+ import { describe, test, expect, mock, beforeEach, afterEach } 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(), 'ingress-reconcile-test-'));
8
+
9
+ // Track loadRawConfig / saveRawConfig calls
10
+ let rawConfigStore: Record<string, unknown> = {};
11
+
12
+ mock.module('../config/loader.js', () => ({
13
+ getConfig: () => ({}),
14
+ loadConfig: () => ({}),
15
+ loadRawConfig: () => ({ ...rawConfigStore }),
16
+ saveRawConfig: (cfg: Record<string, unknown>) => {
17
+ rawConfigStore = { ...cfg };
18
+ },
19
+ saveConfig: () => {},
20
+ invalidateConfigCache: () => {},
21
+ }));
22
+
23
+ // readHttpToken return value — controlled per test
24
+ let httpTokenValue: string | null = null;
25
+
26
+ mock.module('../util/platform.js', () => ({
27
+ getRootDir: () => testDir,
28
+ getDataDir: () => testDir,
29
+ getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
30
+ isMacOS: () => process.platform === 'darwin',
31
+ isLinux: () => process.platform === 'linux',
32
+ isWindows: () => process.platform === 'win32',
33
+ getSocketPath: () => join(testDir, 'test.sock'),
34
+ getPidPath: () => join(testDir, 'test.pid'),
35
+ getDbPath: () => join(testDir, 'test.db'),
36
+ getLogPath: () => join(testDir, 'test.log'),
37
+ ensureDataDir: () => {},
38
+ readHttpToken: () => httpTokenValue,
39
+ }));
40
+
41
+ mock.module('../util/logger.js', () => ({
42
+ getLogger: () => ({
43
+ info: () => {},
44
+ warn: () => {},
45
+ error: () => {},
46
+ debug: () => {},
47
+ trace: () => {},
48
+ fatal: () => {},
49
+ isDebug: () => false,
50
+ child: () => ({
51
+ info: () => {},
52
+ warn: () => {},
53
+ error: () => {},
54
+ debug: () => {},
55
+ isDebug: () => false,
56
+ }),
57
+ }),
58
+ }));
59
+
60
+ // Mock providers registry to avoid side effects
61
+ mock.module('../providers/registry.js', () => ({
62
+ initializeProviders: () => {},
63
+ }));
64
+
65
+ import { handleIngressConfig } from '../daemon/handlers/config.js';
66
+ import type { HandlerContext } from '../daemon/handlers/shared.js';
67
+ import type {
68
+ IngressConfigRequest,
69
+ ServerMessage,
70
+ } from '../daemon/ipc-contract.js';
71
+ import { DebouncerMap } from '../util/debounce.js';
72
+
73
+ // Capture fetch calls for reconcile trigger verification
74
+ interface ReconcileCall {
75
+ url: string;
76
+ method: string;
77
+ headers: Record<string, string>;
78
+ body: string;
79
+ }
80
+
81
+ let reconcileCalls: ReconcileCall[] = [];
82
+ let fetchShouldFail = false;
83
+ const originalFetch = globalThis.fetch;
84
+
85
+ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
86
+ const sent: ServerMessage[] = [];
87
+ const ctx: HandlerContext = {
88
+ sessions: new Map(),
89
+ socketToSession: new Map(),
90
+ cuSessions: new Map(),
91
+ socketToCuSession: new Map(),
92
+ cuObservationParseSequence: new Map(),
93
+ socketSandboxOverride: new Map(),
94
+ sharedRequestTimestamps: [],
95
+ debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
96
+ suppressConfigReload: false,
97
+ setSuppressConfigReload: () => {},
98
+ updateConfigFingerprint: () => {},
99
+ send: (_socket, msg) => { sent.push(msg); },
100
+ broadcast: () => {},
101
+ clearAllSessions: () => 0,
102
+ getOrCreateSession: () => { throw new Error('not implemented'); },
103
+ touchSession: () => {},
104
+ };
105
+ return { ctx, sent };
106
+ }
107
+
108
+ describe('Ingress reconcile trigger in handleIngressConfig', () => {
109
+ let savedIngressEnv: string | undefined;
110
+ let savedGatewayBaseEnv: string | undefined;
111
+ let savedGatewayPortEnv: string | undefined;
112
+
113
+ beforeEach(() => {
114
+ rawConfigStore = {};
115
+ httpTokenValue = null;
116
+ reconcileCalls = [];
117
+ fetchShouldFail = false;
118
+
119
+ savedIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
120
+ savedGatewayBaseEnv = process.env.GATEWAY_INTERNAL_BASE_URL;
121
+ savedGatewayPortEnv = process.env.GATEWAY_PORT;
122
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
123
+ delete process.env.GATEWAY_INTERNAL_BASE_URL;
124
+ delete process.env.GATEWAY_PORT;
125
+
126
+ // Install fetch interceptor
127
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
128
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
129
+ if (urlStr.includes('/internal/telegram/reconcile')) {
130
+ const headers: Record<string, string> = {};
131
+ if (init?.headers) {
132
+ const h = init.headers as Record<string, string>;
133
+ for (const [k, v] of Object.entries(h)) {
134
+ headers[k] = v;
135
+ }
136
+ }
137
+ reconcileCalls.push({
138
+ url: urlStr,
139
+ method: init?.method ?? 'GET',
140
+ headers,
141
+ body: (init?.body as string) ?? '',
142
+ });
143
+ if (fetchShouldFail) {
144
+ throw new Error('ECONNREFUSED: gateway unavailable');
145
+ }
146
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
147
+ }
148
+ return originalFetch(url, init);
149
+ }) as typeof fetch;
150
+ });
151
+
152
+ afterEach(() => {
153
+ globalThis.fetch = originalFetch;
154
+ if (savedIngressEnv !== undefined) {
155
+ process.env.INGRESS_PUBLIC_BASE_URL = savedIngressEnv;
156
+ } else {
157
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
158
+ }
159
+ if (savedGatewayBaseEnv !== undefined) {
160
+ process.env.GATEWAY_INTERNAL_BASE_URL = savedGatewayBaseEnv;
161
+ } else {
162
+ delete process.env.GATEWAY_INTERNAL_BASE_URL;
163
+ }
164
+ if (savedGatewayPortEnv !== undefined) {
165
+ process.env.GATEWAY_PORT = savedGatewayPortEnv;
166
+ } else {
167
+ delete process.env.GATEWAY_PORT;
168
+ }
169
+ });
170
+
171
+ // ── Token present/missing behavior ──────────────────────────────────────
172
+
173
+ test('skips reconcile trigger when no HTTP bearer token is available', async () => {
174
+ httpTokenValue = null;
175
+
176
+ const msg: IngressConfigRequest = {
177
+ type: 'ingress_config',
178
+ action: 'set',
179
+ publicBaseUrl: 'https://my-tunnel.example.com',
180
+ enabled: true,
181
+ };
182
+
183
+ const { ctx, sent } = createTestContext();
184
+ handleIngressConfig(msg, {} as net.Socket, ctx);
185
+
186
+ // Allow any pending microtasks to flush
187
+ await new Promise((r) => setTimeout(r, 50));
188
+
189
+ expect(sent).toHaveLength(1);
190
+ const res = sent[0] as { type: string; success: boolean };
191
+ expect(res.success).toBe(true);
192
+
193
+ // No reconcile call should have been made
194
+ expect(reconcileCalls).toHaveLength(0);
195
+ });
196
+
197
+ test('triggers reconcile when HTTP bearer token is available', async () => {
198
+ httpTokenValue = 'test-bearer-token';
199
+
200
+ const msg: IngressConfigRequest = {
201
+ type: 'ingress_config',
202
+ action: 'set',
203
+ publicBaseUrl: 'https://my-tunnel.example.com',
204
+ enabled: true,
205
+ };
206
+
207
+ const { ctx, sent } = createTestContext();
208
+ handleIngressConfig(msg, {} as net.Socket, ctx);
209
+
210
+ await new Promise((r) => setTimeout(r, 50));
211
+
212
+ expect(sent).toHaveLength(1);
213
+ expect(reconcileCalls).toHaveLength(1);
214
+ expect(reconcileCalls[0]!.headers['Authorization']).toBe('Bearer test-bearer-token');
215
+ });
216
+
217
+ // ── Request payload normalization ───────────────────────────────────────
218
+
219
+ test('sends ingressPublicBaseUrl in reconcile body when URL is set', async () => {
220
+ httpTokenValue = 'test-token';
221
+
222
+ const msg: IngressConfigRequest = {
223
+ type: 'ingress_config',
224
+ action: 'set',
225
+ publicBaseUrl: 'https://my-tunnel.example.com',
226
+ enabled: true,
227
+ };
228
+
229
+ const { ctx } = createTestContext();
230
+ handleIngressConfig(msg, {} as net.Socket, ctx);
231
+
232
+ await new Promise((r) => setTimeout(r, 50));
233
+
234
+ expect(reconcileCalls).toHaveLength(1);
235
+ const body = JSON.parse(reconcileCalls[0]!.body);
236
+ expect(body.ingressPublicBaseUrl).toBe('https://my-tunnel.example.com');
237
+ });
238
+
239
+ test('sends POST to /internal/telegram/reconcile with correct content type', async () => {
240
+ httpTokenValue = 'test-token';
241
+
242
+ const msg: IngressConfigRequest = {
243
+ type: 'ingress_config',
244
+ action: 'set',
245
+ publicBaseUrl: 'https://example.com',
246
+ enabled: true,
247
+ };
248
+
249
+ const { ctx } = createTestContext();
250
+ handleIngressConfig(msg, {} as net.Socket, ctx);
251
+
252
+ await new Promise((r) => setTimeout(r, 50));
253
+
254
+ expect(reconcileCalls).toHaveLength(1);
255
+ expect(reconcileCalls[0]!.method).toBe('POST');
256
+ expect(reconcileCalls[0]!.headers['Content-Type']).toBe('application/json');
257
+ });
258
+
259
+ test('normalizes trailing slashes in publicBaseUrl before sending reconcile', async () => {
260
+ httpTokenValue = 'test-token';
261
+
262
+ const msg: IngressConfigRequest = {
263
+ type: 'ingress_config',
264
+ action: 'set',
265
+ publicBaseUrl: 'https://my-tunnel.example.com///',
266
+ enabled: true,
267
+ };
268
+
269
+ const { ctx } = createTestContext();
270
+ handleIngressConfig(msg, {} as net.Socket, ctx);
271
+
272
+ await new Promise((r) => setTimeout(r, 50));
273
+
274
+ expect(reconcileCalls).toHaveLength(1);
275
+ const body = JSON.parse(reconcileCalls[0]!.body);
276
+ // The handler trims trailing slashes before storing and propagating
277
+ expect(body.ingressPublicBaseUrl).toBe('https://my-tunnel.example.com');
278
+ });
279
+
280
+ test('uses GATEWAY_INTERNAL_BASE_URL when set', async () => {
281
+ httpTokenValue = 'test-token';
282
+ process.env.GATEWAY_INTERNAL_BASE_URL = 'http://custom-gateway:9999';
283
+
284
+ const msg: IngressConfigRequest = {
285
+ type: 'ingress_config',
286
+ action: 'set',
287
+ publicBaseUrl: 'https://example.com',
288
+ enabled: true,
289
+ };
290
+
291
+ const { ctx } = createTestContext();
292
+ handleIngressConfig(msg, {} as net.Socket, ctx);
293
+
294
+ await new Promise((r) => setTimeout(r, 50));
295
+
296
+ expect(reconcileCalls).toHaveLength(1);
297
+ expect(reconcileCalls[0]!.url).toBe('http://custom-gateway:9999/internal/telegram/reconcile');
298
+ });
299
+
300
+ test('defaults to localhost:7830 when no GATEWAY env vars set', async () => {
301
+ httpTokenValue = 'test-token';
302
+
303
+ const msg: IngressConfigRequest = {
304
+ type: 'ingress_config',
305
+ action: 'set',
306
+ publicBaseUrl: 'https://example.com',
307
+ enabled: true,
308
+ };
309
+
310
+ const { ctx } = createTestContext();
311
+ handleIngressConfig(msg, {} as net.Socket, ctx);
312
+
313
+ await new Promise((r) => setTimeout(r, 50));
314
+
315
+ expect(reconcileCalls).toHaveLength(1);
316
+ expect(reconcileCalls[0]!.url).toBe('http://127.0.0.1:7830/internal/telegram/reconcile');
317
+ });
318
+
319
+ test('uses GATEWAY_PORT when GATEWAY_INTERNAL_BASE_URL is not set', async () => {
320
+ httpTokenValue = 'test-token';
321
+ process.env.GATEWAY_PORT = '8888';
322
+
323
+ const msg: IngressConfigRequest = {
324
+ type: 'ingress_config',
325
+ action: 'set',
326
+ publicBaseUrl: 'https://example.com',
327
+ enabled: true,
328
+ };
329
+
330
+ const { ctx } = createTestContext();
331
+ handleIngressConfig(msg, {} as net.Socket, ctx);
332
+
333
+ await new Promise((r) => setTimeout(r, 50));
334
+
335
+ expect(reconcileCalls).toHaveLength(1);
336
+ expect(reconcileCalls[0]!.url).toBe('http://127.0.0.1:8888/internal/telegram/reconcile');
337
+ });
338
+
339
+ // ── Non-fatal failure behavior ──────────────────────────────────────────
340
+
341
+ test('reconcile failure does not cause handleIngressConfig to fail', async () => {
342
+ httpTokenValue = 'test-token';
343
+ fetchShouldFail = true;
344
+
345
+ const msg: IngressConfigRequest = {
346
+ type: 'ingress_config',
347
+ action: 'set',
348
+ publicBaseUrl: 'https://my-tunnel.example.com',
349
+ enabled: true,
350
+ };
351
+
352
+ const { ctx, sent } = createTestContext();
353
+ handleIngressConfig(msg, {} as net.Socket, ctx);
354
+
355
+ await new Promise((r) => setTimeout(r, 50));
356
+
357
+ // The handler should still succeed even though reconcile fetch threw
358
+ expect(sent).toHaveLength(1);
359
+ const res = sent[0] as { type: string; success: boolean; enabled: boolean; publicBaseUrl: string };
360
+ expect(res.type).toBe('ingress_config_response');
361
+ expect(res.success).toBe(true);
362
+ expect(res.enabled).toBe(true);
363
+ expect(res.publicBaseUrl).toBe('https://my-tunnel.example.com');
364
+
365
+ // The reconcile attempt was still made (it just failed gracefully)
366
+ expect(reconcileCalls).toHaveLength(1);
367
+ });
368
+
369
+ test('response is sent before reconcile fetch completes', async () => {
370
+ httpTokenValue = 'test-token';
371
+
372
+ // Track timing: response should be sent before fetch resolves
373
+ let fetchResolved = false;
374
+ const originalMockFetch = globalThis.fetch;
375
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
376
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
377
+ if (urlStr.includes('/internal/telegram/reconcile')) {
378
+ // Delay the response to simulate network latency
379
+ await new Promise((r) => setTimeout(r, 100));
380
+ fetchResolved = true;
381
+ reconcileCalls.push({
382
+ url: urlStr,
383
+ method: init?.method ?? 'GET',
384
+ headers: {},
385
+ body: (init?.body as string) ?? '',
386
+ });
387
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
388
+ }
389
+ return originalFetch(url, init);
390
+ }) as typeof fetch;
391
+
392
+ const msg: IngressConfigRequest = {
393
+ type: 'ingress_config',
394
+ action: 'set',
395
+ publicBaseUrl: 'https://example.com',
396
+ enabled: true,
397
+ };
398
+
399
+ const { ctx, sent } = createTestContext();
400
+ handleIngressConfig(msg, {} as net.Socket, ctx);
401
+
402
+ // Response should be available immediately (before fetch resolves)
403
+ expect(sent).toHaveLength(1);
404
+ expect(fetchResolved).toBe(false);
405
+
406
+ // Clean up: wait for the delayed fetch to complete
407
+ await new Promise((r) => setTimeout(r, 150));
408
+ globalThis.fetch = originalMockFetch;
409
+ });
410
+
411
+ // ── Set flow ────────────────────────────────────────────────────────────
412
+
413
+ test('set action with enabled=true and URL triggers reconcile with the URL', async () => {
414
+ httpTokenValue = 'test-token';
415
+
416
+ const msg: IngressConfigRequest = {
417
+ type: 'ingress_config',
418
+ action: 'set',
419
+ publicBaseUrl: 'https://set-test.example.com',
420
+ enabled: true,
421
+ };
422
+
423
+ const { ctx, sent } = createTestContext();
424
+ handleIngressConfig(msg, {} as net.Socket, ctx);
425
+
426
+ await new Promise((r) => setTimeout(r, 50));
427
+
428
+ expect(sent).toHaveLength(1);
429
+ const res = sent[0] as { type: string; success: boolean; enabled: boolean };
430
+ expect(res.success).toBe(true);
431
+ expect(res.enabled).toBe(true);
432
+
433
+ expect(reconcileCalls).toHaveLength(1);
434
+ const body = JSON.parse(reconcileCalls[0]!.body);
435
+ expect(body.ingressPublicBaseUrl).toBe('https://set-test.example.com');
436
+ });
437
+
438
+ // ── Clear flow ──────────────────────────────────────────────────────────
439
+
440
+ test('set action with empty URL and enabled=true (clear URL) still triggers reconcile', async () => {
441
+ httpTokenValue = 'test-token';
442
+
443
+ const msg: IngressConfigRequest = {
444
+ type: 'ingress_config',
445
+ action: 'set',
446
+ publicBaseUrl: '',
447
+ enabled: true,
448
+ };
449
+
450
+ const { ctx, sent } = createTestContext();
451
+ handleIngressConfig(msg, {} as net.Socket, ctx);
452
+
453
+ await new Promise((r) => setTimeout(r, 50));
454
+
455
+ expect(sent).toHaveLength(1);
456
+ const res = sent[0] as { type: string; success: boolean };
457
+ expect(res.success).toBe(true);
458
+
459
+ // Reconcile is called unconditionally on set action
460
+ // When no URL and no env fallback, effectiveUrl is undefined so
461
+ // the reconcile body should send empty string (clears the gateway's URL)
462
+ expect(reconcileCalls).toHaveLength(1);
463
+ const body = JSON.parse(reconcileCalls[0]!.body);
464
+ expect(body.ingressPublicBaseUrl).toBe('');
465
+ });
466
+
467
+ // ── Disable flow ────────────────────────────────────────────────────────
468
+
469
+ test('set action with enabled=false triggers reconcile with empty URL', async () => {
470
+ httpTokenValue = 'test-token';
471
+
472
+ const msg: IngressConfigRequest = {
473
+ type: 'ingress_config',
474
+ action: 'set',
475
+ publicBaseUrl: 'https://disabled-test.example.com',
476
+ enabled: false,
477
+ };
478
+
479
+ const { ctx, sent } = createTestContext();
480
+ handleIngressConfig(msg, {} as net.Socket, ctx);
481
+
482
+ await new Promise((r) => setTimeout(r, 50));
483
+
484
+ expect(sent).toHaveLength(1);
485
+ const res = sent[0] as { type: string; success: boolean; enabled: boolean };
486
+ expect(res.success).toBe(true);
487
+ expect(res.enabled).toBe(false);
488
+
489
+ // Reconcile should still fire (to clear gateway's in-memory URL)
490
+ expect(reconcileCalls).toHaveLength(1);
491
+ const body = JSON.parse(reconcileCalls[0]!.body);
492
+ // When disabled, effectiveUrl is undefined, so the body sends empty string
493
+ expect(body.ingressPublicBaseUrl).toBe('');
494
+ });
495
+
496
+ test('disabling ingress removes INGRESS_PUBLIC_BASE_URL env var', () => {
497
+ httpTokenValue = 'test-token';
498
+
499
+ // First set ingress to populate env var
500
+ process.env.INGRESS_PUBLIC_BASE_URL = 'https://should-be-removed.example.com';
501
+
502
+ const msg: IngressConfigRequest = {
503
+ type: 'ingress_config',
504
+ action: 'set',
505
+ publicBaseUrl: 'https://disabled-test.example.com',
506
+ enabled: false,
507
+ };
508
+
509
+ const { ctx } = createTestContext();
510
+ handleIngressConfig(msg, {} as net.Socket, ctx);
511
+
512
+ // Env var should be cleared
513
+ expect(process.env.INGRESS_PUBLIC_BASE_URL).toBeUndefined();
514
+ });
515
+
516
+ // ── Get action does not trigger reconcile ───────────────────────────────
517
+
518
+ test('get action does not trigger reconcile', async () => {
519
+ httpTokenValue = 'test-token';
520
+ rawConfigStore = {
521
+ ingress: { publicBaseUrl: 'https://existing.example.com', enabled: true },
522
+ };
523
+
524
+ const msg: IngressConfigRequest = {
525
+ type: 'ingress_config',
526
+ action: 'get',
527
+ };
528
+
529
+ const { ctx, sent } = createTestContext();
530
+ handleIngressConfig(msg, {} as net.Socket, ctx);
531
+
532
+ await new Promise((r) => setTimeout(r, 50));
533
+
534
+ expect(sent).toHaveLength(1);
535
+ const res = sent[0] as { type: string; success: boolean; publicBaseUrl: string };
536
+ expect(res.success).toBe(true);
537
+ expect(res.publicBaseUrl).toBe('https://existing.example.com');
538
+
539
+ // No reconcile should have been triggered for a get action
540
+ expect(reconcileCalls).toHaveLength(0);
541
+ });
542
+
543
+ // ── Env var propagation ─────────────────────────────────────────────────
544
+
545
+ test('set action propagates URL to process.env when enabled', () => {
546
+ httpTokenValue = 'test-token';
547
+
548
+ const msg: IngressConfigRequest = {
549
+ type: 'ingress_config',
550
+ action: 'set',
551
+ publicBaseUrl: 'https://env-propagation.example.com',
552
+ enabled: true,
553
+ };
554
+
555
+ const { ctx } = createTestContext();
556
+ handleIngressConfig(msg, {} as net.Socket, ctx);
557
+
558
+ expect(process.env.INGRESS_PUBLIC_BASE_URL).toBe('https://env-propagation.example.com');
559
+ });
560
+
561
+ test('reconcile uses effective URL from process.env (not raw value)', async () => {
562
+ httpTokenValue = 'test-token';
563
+
564
+ const msg: IngressConfigRequest = {
565
+ type: 'ingress_config',
566
+ action: 'set',
567
+ publicBaseUrl: 'https://effective-url.example.com',
568
+ enabled: true,
569
+ };
570
+
571
+ const { ctx } = createTestContext();
572
+ handleIngressConfig(msg, {} as net.Socket, ctx);
573
+
574
+ await new Promise((r) => setTimeout(r, 50));
575
+
576
+ expect(reconcileCalls).toHaveLength(1);
577
+ const body = JSON.parse(reconcileCalls[0]!.body);
578
+ // The URL in the reconcile body should match the effective env var
579
+ expect(body.ingressPublicBaseUrl).toBe('https://effective-url.example.com');
580
+ });
581
+ });