@vellumai/cli 0.11.2 → 0.11.3-staging.2

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 (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -1,4 +1,7 @@
1
1
  import * as childProcess from "node:child_process";
2
+ import type { ChildProcess } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import * as fsModule from "node:fs";
2
5
  import {
3
6
  existsSync,
4
7
  mkdirSync,
@@ -10,30 +13,143 @@ import {
10
13
  import { tmpdir } from "node:os";
11
14
  import { join } from "node:path";
12
15
 
13
- import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
16
+ import {
17
+ afterAll,
18
+ afterEach,
19
+ beforeEach,
20
+ describe,
21
+ expect,
22
+ mock,
23
+ test,
24
+ } from "bun:test";
25
+
26
+ import * as featureFlags from "../lib/feature-flags.js";
27
+ import * as httpClient from "../lib/http-client.js";
14
28
 
15
29
  const realChildProcess = { ...childProcess };
30
+ const realFs = { ...fsModule };
31
+ const realFeatureFlags = { ...featureFlags };
32
+ const realHttpClient = { ...httpClient };
33
+
16
34
  const execFileSyncMock = mock(childProcess.execFileSync);
35
+ const spawnSyncMock = mock(childProcess.spawnSync);
36
+ const spawnMock = mock(childProcess.spawn);
17
37
 
18
38
  mock.module("node:child_process", () => ({
19
39
  ...childProcess,
20
40
  execFileSync: execFileSyncMock,
41
+ spawnSync: spawnSyncMock,
42
+ spawn: spawnMock,
43
+ }));
44
+
45
+ const existsSyncMock = mock(fsModule.existsSync);
46
+ const readFileSyncMock = mock(fsModule.readFileSync);
47
+
48
+ mock.module("node:fs", () => ({
49
+ ...fsModule,
50
+ existsSync: existsSyncMock,
51
+ readFileSync: readFileSyncMock,
52
+ }));
53
+
54
+ const waitForDaemonReadyMock = mock<typeof httpClient.waitForDaemonReady>(
55
+ async () => true,
56
+ );
57
+
58
+ mock.module("../lib/http-client.js", () => ({
59
+ ...httpClient,
60
+ waitForDaemonReady: waitForDaemonReadyMock,
61
+ }));
62
+
63
+ const isFeatureFlagEnabledMock = mock<
64
+ typeof featureFlags.isAssistantFeatureFlagEnabled
65
+ >(async () => true);
66
+
67
+ mock.module("../lib/feature-flags.js", () => ({
68
+ ...featureFlags,
69
+ isAssistantFeatureFlagEnabled: isFeatureFlagEnabledMock,
21
70
  }));
22
71
 
23
- // Restore the real module once this file finishes so the mock does not leak
72
+ // Restore the real modules once this file finishes so the mocks do not leak
24
73
  // into sibling test files in the same `bun test` run.
25
74
  afterAll(() => {
26
75
  mock.module("node:child_process", () => realChildProcess);
76
+ mock.module("node:fs", () => realFs);
77
+ mock.module("../lib/feature-flags.js", () => realFeatureFlags);
78
+ mock.module("../lib/http-client.js", () => realHttpClient);
27
79
  });
28
80
 
29
81
  import {
30
82
  buildIngressNginxConfig,
83
+ hasIpv6Loopback,
31
84
  buildRemoteWebIndexHtml,
32
- resolveTunnelTargetPort,
85
+ cloudWebHubUrl,
86
+ ensureTunnelEdge,
87
+ startRemoteWebIngress,
33
88
  stopIngressNginx,
34
89
  } from "../lib/nginx-ingress.js";
35
90
 
36
91
  const originalKill = process.kill;
92
+ const workspaces: string[] = [];
93
+
94
+ /**
95
+ * Registry-backed process.kill mock: registered fake PIDs answer liveness
96
+ * probes and record kills, everything else forwards to the real kill. Lets
97
+ * helpers compose (old-edge PID and freshly-spawned master PID at once).
98
+ */
99
+ const fakePids = new Map<number, { alive: boolean; unkillable?: boolean }>();
100
+
101
+ function installKillMock(): void {
102
+ process.kill = mock((targetPid: number, signal?: string | number) => {
103
+ const entry = fakePids.get(targetPid);
104
+ if (!entry) {
105
+ return originalKill(targetPid, signal);
106
+ }
107
+ if (signal === 0) {
108
+ if (!entry.alive) {
109
+ throw new Error("dead");
110
+ }
111
+ return true;
112
+ }
113
+ if (entry.unkillable) {
114
+ throw new Error("operation not permitted");
115
+ }
116
+ entry.alive = false;
117
+ return true;
118
+ }) as unknown as typeof process.kill;
119
+ }
120
+
121
+ afterEach(() => {
122
+ process.kill = originalKill;
123
+ fakePids.clear();
124
+ execFileSyncMock.mockReset();
125
+ spawnSyncMock.mockReset();
126
+ spawnSyncMock.mockImplementation(realChildProcess.spawnSync);
127
+ spawnMock.mockReset();
128
+ spawnMock.mockImplementation(realChildProcess.spawn);
129
+ existsSyncMock.mockReset();
130
+ existsSyncMock.mockImplementation(realFs.existsSync);
131
+ readFileSyncMock.mockReset();
132
+ readFileSyncMock.mockImplementation(realFs.readFileSync);
133
+ waitForDaemonReadyMock.mockReset();
134
+ waitForDaemonReadyMock.mockImplementation(async () => true);
135
+ isFeatureFlagEnabledMock.mockReset();
136
+ isFeatureFlagEnabledMock.mockImplementation(async () => true);
137
+ for (const dir of workspaces.splice(0)) {
138
+ rmSync(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ function makeWorkspace(): string {
143
+ const dir = mkdtempSync(join(tmpdir(), "vellum-ingress-test-"));
144
+ workspaces.push(dir);
145
+ return dir;
146
+ }
147
+
148
+ function readConfig(workspaceDir: string): Record<string, unknown> {
149
+ return JSON.parse(
150
+ readFileSync(join(workspaceDir, "config.json"), "utf-8"),
151
+ ) as Record<string, unknown>;
152
+ }
37
153
 
38
154
  describe("buildIngressNginxConfig", () => {
39
155
  const conf = buildIngressNginxConfig({ gatewayPort: 7830, listenPort: 7840 });
@@ -55,10 +171,25 @@ describe("buildIngressNginxConfig", () => {
55
171
  const listens = conf.match(/listen [^;]+;/g) ?? [];
56
172
  expect(listens.length).toBeGreaterThan(0);
57
173
  for (const directive of listens) {
58
- expect(directive).toContain("127.0.0.1");
174
+ expect(directive).toMatch(/127\.0\.0\.1|\[::1\]/);
59
175
  }
60
176
  });
61
177
 
178
+ test("adds the IPv6 loopback listener only where ::1 exists", () => {
179
+ // A tunnel agent pointed at "localhost" reaches ::1 first on macOS, so an
180
+ // IPv4-only bind refuses whichever share of a burst resolves that way.
181
+ // Emitting it unconditionally would make nginx exit on an IPv6-less host.
182
+ const withIpv6 = buildIngressNginxConfig({
183
+ gatewayPort: 7830,
184
+ listenPort: 7840,
185
+ ipv6Loopback: true,
186
+ });
187
+ expect(withIpv6).toContain("listen 127.0.0.1:7840;");
188
+ expect(withIpv6).toContain("listen [::1]:7840;");
189
+
190
+ expect(conf).not.toContain("[::1]");
191
+ });
192
+
62
193
  test("emits relative redirects so a fronting proxy keeps scheme and port", () => {
63
194
  for (const config of [conf, remoteConf]) {
64
195
  expect(config).toContain("absolute_redirect off;");
@@ -70,10 +201,21 @@ describe("buildIngressNginxConfig", () => {
70
201
  expect(conf).toContain("location / {");
71
202
  expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
72
203
  expect(conf).toContain('proxy_set_header X-Vellum-Edge-Forwarded "1";');
73
- expect(conf).not.toContain("return 404;");
74
- expect(conf).not.toContain("return 403;");
75
- expect(conf).not.toContain("location =");
76
- expect(conf).not.toContain("location ~");
204
+ });
205
+
206
+ test("blocks local-only bootstrap helpers before the catch-all proxy", () => {
207
+ const catchAll = conf.indexOf("location / {");
208
+ expect(catchAll).toBeGreaterThan(-1);
209
+ const deniedLocations = [
210
+ "location = /auth/token { return 404; }",
211
+ "location = /v1/pair { return 404; }",
212
+ "location = /v1/guardian/init { return 404; }",
213
+ "location = /v1/remote-web/pairing-verification { return 404; }",
214
+ ];
215
+ for (const location of deniedLocations) {
216
+ expect(conf).toContain(location);
217
+ expect(conf.indexOf(location)).toBeLessThan(catchAll);
218
+ }
77
219
  });
78
220
 
79
221
  test("declares static MIME types needed by the SPA", () => {
@@ -122,6 +264,21 @@ describe("buildIngressNginxConfig", () => {
122
264
  );
123
265
  });
124
266
 
267
+ test("stamps assistantName and hubUrl into the served config when provided", () => {
268
+ const named = buildIngressNginxConfig({
269
+ gatewayPort: 7830,
270
+ listenPort: 7840,
271
+ remoteWebIngress: {
272
+ webDistDir: "/tmp/vellum web/dist",
273
+ assistantName: "Homelab",
274
+ hubUrl: "https://www.vellum.ai/assistant",
275
+ },
276
+ });
277
+ expect(named).toContain(
278
+ 'return 200 "{\\"mode\\":\\"remote-gateway\\",\\"apiBaseUrl\\":\\"/v1\\",\\"platformDisabled\\":true,\\"disablePlatform\\":true,\\"assistantName\\":\\"Homelab\\",\\"hubUrl\\":\\"https://www.vellum.ai/assistant\\"}";',
279
+ );
280
+ });
281
+
125
282
  test("proxies health and public API traffic to the gateway in remote web mode", () => {
126
283
  expect(remoteConf).toContain("location = /healthz {");
127
284
  expect(remoteConf).toContain("location ^~ /v1/ {");
@@ -133,6 +290,26 @@ describe("buildIngressNginxConfig", () => {
133
290
  );
134
291
  });
135
292
 
293
+ test("proxies webhook callbacks to the gateway in remote web mode", () => {
294
+ const webhooksStart = remoteConf.indexOf("location ^~ /webhooks/ {");
295
+ expect(webhooksStart).toBeGreaterThan(-1);
296
+ const webhooksBlock = remoteConf.slice(
297
+ webhooksStart,
298
+ remoteConf.indexOf("}", webhooksStart),
299
+ );
300
+ expect(webhooksBlock).toContain("proxy_pass http://127.0.0.1:7830;");
301
+ expect(webhooksBlock).toContain("proxy_set_header Upgrade $http_upgrade;");
302
+ });
303
+
304
+ test("proxies /healthz in both modes", () => {
305
+ expect(remoteConf).toContain("location = /healthz {");
306
+ // The plain-proxy mode has no /healthz denylist entry, so the catch-all
307
+ // proxy serves it.
308
+ expect(conf).not.toContain("/healthz { return 404; }");
309
+ expect(conf).toContain("location / {");
310
+ expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
311
+ });
312
+
136
313
  test("blocks local-only bootstrap helpers before generic API proxying", () => {
137
314
  const deniedLocations = [
138
315
  "location = /auth/token { return 404; }",
@@ -151,6 +328,7 @@ describe("buildIngressNginxConfig", () => {
151
328
  "location = /v1/guardian/reset-bootstrap/ { return 404; }",
152
329
  "location ^~ /assistant/__local/ { return 404; }",
153
330
  "location ^~ /assistant/__gateway/ { return 404; }",
331
+ "location ^~ /assistant/__gateway-paired/ { return 404; }",
154
332
  ];
155
333
  for (const location of deniedLocations) {
156
334
  expect(remoteConf).toContain(location);
@@ -200,25 +378,47 @@ describe("buildRemoteWebIndexHtml", () => {
200
378
  expect(result).not.toContain("</script><script>alert(1)</script>");
201
379
  expect(result).toContain("\\u003c/script\\u003e");
202
380
  });
203
- });
204
381
 
205
- describe("nginx ingress process state", () => {
206
- const workspaces: string[] = [];
382
+ test("drops modulepreload hints but keeps the entry script and stylesheet", () => {
383
+ const html = [
384
+ "<html><head>",
385
+ '<link rel="modulepreload" crossorigin href="/assistant/assets/a-1.js">',
386
+ '<link rel="modulepreload" crossorigin href="/assistant/assets/b-2.js">',
387
+ '<link rel="stylesheet" crossorigin href="/assistant/assets/main-3.css">',
388
+ '<script type="module" crossorigin src="/assistant/assets/index-4.js"></script>',
389
+ "</head><body></body></html>",
390
+ ].join("\n");
207
391
 
208
- afterEach(() => {
209
- process.kill = originalKill;
210
- execFileSyncMock.mockReset();
211
- for (const dir of workspaces.splice(0)) {
212
- rmSync(dir, { recursive: true, force: true });
213
- }
392
+ const result = buildRemoteWebIndexHtml(html, { mode: "remote-gateway" });
393
+
394
+ expect(result).not.toContain("modulepreload");
395
+ expect(result).not.toContain("/assistant/assets/a-1.js");
396
+ expect(result).toContain('href="/assistant/assets/main-3.css"');
397
+ expect(result).toContain('src="/assistant/assets/index-4.js"');
214
398
  });
399
+ });
215
400
 
216
- function makeWorkspace(): string {
217
- const dir = mkdtempSync(join(tmpdir(), "vellum-ingress-test-"));
218
- workspaces.push(dir);
219
- return dir;
220
- }
401
+ describe("cloudWebHubUrl", () => {
402
+ test("maps build environments to their cloud SPA base", () => {
403
+ expect(cloudWebHubUrl("production")).toBe(
404
+ "https://www.vellum.ai/assistant",
405
+ );
406
+ expect(cloudWebHubUrl("staging")).toBe(
407
+ "https://staging-assistant.vellum.ai/assistant",
408
+ );
409
+ expect(cloudWebHubUrl("dev")).toBe(
410
+ "https://dev-assistant.vellum.ai/assistant",
411
+ );
412
+ expect(cloudWebHubUrl("local")).toBe(
413
+ "https://dev-assistant.vellum.ai/assistant",
414
+ );
415
+ expect(cloudWebHubUrl(undefined)).toBe(
416
+ "https://dev-assistant.vellum.ai/assistant",
417
+ );
418
+ });
419
+ });
221
420
 
421
+ describe("nginx ingress process state", () => {
222
422
  function writeIngressState(workspaceDir: string, listenPort: number): void {
223
423
  writeFileSync(
224
424
  join(workspaceDir, "config.json"),
@@ -232,12 +432,6 @@ describe("nginx ingress process state", () => {
232
432
  writeFileSync(join(dir, "nginx.pid"), `${pid}\n`);
233
433
  }
234
434
 
235
- function readConfig(workspaceDir: string): Record<string, unknown> {
236
- return JSON.parse(
237
- readFileSync(join(workspaceDir, "config.json"), "utf-8"),
238
- ) as Record<string, unknown>;
239
- }
240
-
241
435
  function pidPath(workspaceDir: string): string {
242
436
  return join(workspaceDir, "data", "ingress", "nginx.pid");
243
437
  }
@@ -247,79 +441,6 @@ describe("nginx ingress process state", () => {
247
441
  return `nginx: master process nginx -p ${dir} -c ${join(dir, "nginx.conf")} -g daemon off;`;
248
442
  }
249
443
 
250
- /** A PID guaranteed dead: a short-lived child that has already exited. */
251
- function deadPid(): number {
252
- const result = childProcess.spawnSync("sh", ["-c", "exit 0"]);
253
- if (!result.pid) throw new Error("failed to spawn probe process");
254
- return result.pid;
255
- }
256
-
257
- test("falls back to the gateway port when no ingress state exists", () => {
258
- const ws = makeWorkspace();
259
- expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
260
- port: 7830,
261
- viaIngress: false,
262
- });
263
- });
264
-
265
- test("falls back when ingress state exists but the process is dead", () => {
266
- const ws = makeWorkspace();
267
- writeIngressState(ws, 7841);
268
- writePidFile(ws, deadPid());
269
- expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
270
- port: 7830,
271
- viaIngress: false,
272
- });
273
- });
274
-
275
- test("falls back when the recorded PID belongs to a non-nginx process", () => {
276
- const ws = makeWorkspace();
277
- writeIngressState(ws, 7841);
278
- writePidFile(ws, process.pid);
279
- execFileSyncMock.mockReturnValue("bun test");
280
- expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
281
- port: 7830,
282
- viaIngress: false,
283
- });
284
- });
285
-
286
- test("falls back when the recorded PID belongs to another nginx instance", () => {
287
- const ws = makeWorkspace();
288
- writeIngressState(ws, 7841);
289
- writePidFile(ws, process.pid);
290
- execFileSyncMock.mockReturnValue(
291
- "nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
292
- );
293
- expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
294
- port: 7830,
295
- viaIngress: false,
296
- });
297
- });
298
-
299
- test("targets the ingress when state exists and the PID is this nginx", () => {
300
- const ws = makeWorkspace();
301
- writeIngressState(ws, 7841);
302
- writePidFile(ws, process.pid);
303
- execFileSyncMock.mockReturnValue(nginxCommand(ws));
304
- expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
305
- port: 7841,
306
- viaIngress: true,
307
- });
308
- });
309
-
310
- test("falls back when nginx ingress is not preferred", () => {
311
- const ws = makeWorkspace();
312
- writeIngressState(ws, 7841);
313
- writePidFile(ws, process.pid);
314
- execFileSyncMock.mockReturnValue(nginxCommand(ws));
315
- expect(
316
- resolveTunnelTargetPort(ws, 7830, { preferNginxIngress: false }),
317
- ).toEqual({
318
- port: 7830,
319
- viaIngress: false,
320
- });
321
- });
322
-
323
444
  test("clears ingress state after nginx is confirmed stopped", async () => {
324
445
  const ws = makeWorkspace();
325
446
  const pid = 123_456;
@@ -413,3 +534,1602 @@ describe("nginx ingress process state", () => {
413
534
  expect(existsSync(pidPath(ws))).toBe(false);
414
535
  });
415
536
  });
537
+
538
+ const NGINX_VERSION = "nginx version: nginx/1.29.0";
539
+ const FAKE_INDEX_HTML = "<html><head></head><body></body></html>";
540
+ const WEB_INDEX_SUFFIX = join("dist", "index.html");
541
+
542
+ function ingressConfPath(workspaceDir: string): string {
543
+ return join(workspaceDir, "data", "ingress", "nginx.conf");
544
+ }
545
+
546
+ function mockNginxInstalled(): void {
547
+ spawnSyncMock.mockReturnValue({
548
+ pid: 4242,
549
+ output: [],
550
+ stdout: "",
551
+ stderr: NGINX_VERSION,
552
+ status: 0,
553
+ signal: null,
554
+ });
555
+ }
556
+
557
+ function mockNginxMissing(): void {
558
+ spawnSyncMock.mockReturnValue({
559
+ pid: 0,
560
+ output: [],
561
+ stdout: "",
562
+ stderr: "",
563
+ status: 1,
564
+ signal: null,
565
+ });
566
+ }
567
+
568
+ const SPAWNED_NGINX_PID = 4243;
569
+
570
+ /**
571
+ * Spawned nginx that wins its bind: alive as the master, and it records its
572
+ * pid under the prefix dir like a real `daemon off` master, with a matching
573
+ * ps answer so getIngressPid confirms ownership.
574
+ */
575
+ function mockNginxSpawn(): void {
576
+ spawnMock.mockImplementation(((_command: string, args: readonly string[]) => {
577
+ const dir = String(args[args.indexOf("-p") + 1]);
578
+ realFs.writeFileSync(join(dir, "nginx.pid"), `${SPAWNED_NGINX_PID}\n`);
579
+ fakePids.set(SPAWNED_NGINX_PID, { alive: true });
580
+ installKillMock();
581
+ execFileSyncMock.mockImplementation(((
582
+ _file: string,
583
+ psArgs?: readonly string[],
584
+ ) =>
585
+ psArgs?.includes(String(SPAWNED_NGINX_PID))
586
+ ? `nginx: master process nginx -p ${dir} -c ${join(dir, "nginx.conf")} -g daemon off;`
587
+ : "") as typeof childProcess.execFileSync);
588
+ return {
589
+ unref: () => {},
590
+ once: () => {},
591
+ exitCode: null,
592
+ pid: SPAWNED_NGINX_PID,
593
+ } as unknown as ChildProcess;
594
+ }) as unknown as typeof childProcess.spawn);
595
+ }
596
+
597
+ /** Spawned nginx that exits on startup, as when the listen port is already bound. */
598
+ function mockNginxSpawnExitsOnStartup(): void {
599
+ spawnMock.mockReturnValue({
600
+ unref: () => {},
601
+ once: (event: string, listener: () => void) => {
602
+ if (event === "exit") listener();
603
+ },
604
+ exitCode: 1,
605
+ pid: SPAWNED_NGINX_PID,
606
+ } as unknown as ChildProcess);
607
+ }
608
+
609
+ /**
610
+ * Spawned nginx that loses the bind race to a healthy squatter: still alive
611
+ * when the probe resolves, exits shortly after, never writes a pid file.
612
+ */
613
+ function mockNginxSpawnExitsAfterProbe(): void {
614
+ spawnMock.mockImplementation((() => {
615
+ const child = {
616
+ unref: () => {},
617
+ exitCode: null as number | null,
618
+ pid: SPAWNED_NGINX_PID,
619
+ once: (event: string, listener: () => void) => {
620
+ if (event === "exit") {
621
+ setTimeout(() => {
622
+ child.exitCode = 1;
623
+ listener();
624
+ }, 150);
625
+ }
626
+ },
627
+ };
628
+ return child as unknown as ChildProcess;
629
+ }) as unknown as typeof childProcess.spawn);
630
+ }
631
+
632
+ /** Force findWebDistDir() to resolve nothing, regardless of checkout state. */
633
+ function mockWebDistMissing(): void {
634
+ existsSyncMock.mockImplementation((path) =>
635
+ String(path).endsWith("index.html") ? false : realFs.existsSync(path),
636
+ );
637
+ }
638
+
639
+ /** Force findWebDistDir() to resolve a dist dir, regardless of checkout state. */
640
+ function mockWebDistPresent(): void {
641
+ existsSyncMock.mockImplementation((path) =>
642
+ String(path).endsWith(WEB_INDEX_SUFFIX) ? true : realFs.existsSync(path),
643
+ );
644
+ readFileSyncMock.mockImplementation(((path, options) =>
645
+ String(path).endsWith(WEB_INDEX_SUFFIX)
646
+ ? FAKE_INDEX_HTML
647
+ : realFs.readFileSync(
648
+ path as never,
649
+ options as never,
650
+ )) as typeof readFileSync);
651
+ }
652
+
653
+ /** Record a running edge: ingress state, live pidfile, matching ps output. */
654
+ function mockRunningEdge(
655
+ ws: string,
656
+ opts: {
657
+ listenPort: number;
658
+ includeWebApp?: boolean;
659
+ gatewayPort?: number;
660
+ remoteWebConfigHash?: string;
661
+ },
662
+ ): number {
663
+ const pid = 123_460;
664
+ writeFileSync(
665
+ join(ws, "config.json"),
666
+ JSON.stringify({
667
+ ingress: {
668
+ nginx: {
669
+ listenPort: opts.listenPort,
670
+ ...(opts.includeWebApp === undefined
671
+ ? {}
672
+ : { includeWebApp: opts.includeWebApp }),
673
+ ...(opts.gatewayPort === undefined
674
+ ? {}
675
+ : { gatewayPort: opts.gatewayPort }),
676
+ ...(opts.remoteWebConfigHash === undefined
677
+ ? {}
678
+ : { remoteWebConfigHash: opts.remoteWebConfigHash }),
679
+ },
680
+ },
681
+ }) + "\n",
682
+ );
683
+ const dir = join(ws, "data", "ingress");
684
+ mkdirSync(dir, { recursive: true });
685
+ writeFileSync(join(dir, "nginx.pid"), `${pid}\n`);
686
+ execFileSyncMock.mockReturnValue(
687
+ `nginx: master process nginx -p ${dir} -c ${join(dir, "nginx.conf")} -g daemon off;`,
688
+ );
689
+ return pid;
690
+ }
691
+
692
+ /** SIGTERM to the given PID marks it dead; signal 0 reflects liveness. */
693
+ function mockKillableNginx(pid: number): { killed: () => boolean } {
694
+ fakePids.set(pid, { alive: true });
695
+ installKillMock();
696
+ return { killed: () => fakePids.get(pid)?.alive === false };
697
+ }
698
+
699
+ /** The given PID stays alive: liveness probes succeed, kill signals fail. */
700
+ function mockUnkillableNginx(pid: number): void {
701
+ fakePids.set(pid, { alive: true, unkillable: true });
702
+ installKillMock();
703
+ }
704
+
705
+ const PRODUCTION_HUB_URL = "https://www.vellum.ai/assistant";
706
+
707
+ /**
708
+ * Mirror of the SPA config fingerprint the edge records in its ingress state
709
+ * (sha256 over the edge template version and the injected config JSON). Pins
710
+ * both the injected config shape and the hash format; assumes the
711
+ * production-pinned environment. `template` tracks `EDGE_TEMPLATE_VERSION`.
712
+ */
713
+ function spaConfigHash(assistantName?: string): string {
714
+ return createHash("sha256")
715
+ .update(
716
+ JSON.stringify({
717
+ template: 2,
718
+ config: {
719
+ mode: "remote-gateway",
720
+ apiBaseUrl: "/v1",
721
+ platformDisabled: true,
722
+ disablePlatform: true,
723
+ ...(assistantName ? { assistantName } : {}),
724
+ hubUrl: PRODUCTION_HUB_URL,
725
+ },
726
+ }),
727
+ )
728
+ .digest("hex");
729
+ }
730
+
731
+ const originalEnvironment = process.env.VELLUM_ENVIRONMENT;
732
+
733
+ /** Pin the build environment so the stamped hubUrl is deterministic. */
734
+ function pinProductionEnvironment(): void {
735
+ beforeEach(() => {
736
+ process.env.VELLUM_ENVIRONMENT = "production";
737
+ });
738
+ afterEach(() => {
739
+ if (originalEnvironment === undefined) {
740
+ delete process.env.VELLUM_ENVIRONMENT;
741
+ } else {
742
+ process.env.VELLUM_ENVIRONMENT = originalEnvironment;
743
+ }
744
+ });
745
+ }
746
+
747
+ describe("startRemoteWebIngress", () => {
748
+ pinProductionEnvironment();
749
+
750
+ test("webhooks-only mode starts the denylist+proxy edge without a web dist", async () => {
751
+ const ws = makeWorkspace();
752
+ mockNginxInstalled();
753
+ mockNginxSpawn();
754
+ mockWebDistMissing();
755
+
756
+ const onStarting = mock(
757
+ (_info: {
758
+ version: string;
759
+ webDistDir: string | null;
760
+ listenPort: number;
761
+ }) => {},
762
+ );
763
+ const result = await startRemoteWebIngress({
764
+ workspaceDir: ws,
765
+ gatewayPort: 7830,
766
+ listenPort: 7845,
767
+ includeWebApp: false,
768
+ onStarting,
769
+ });
770
+
771
+ expect(result).toEqual({
772
+ status: "started",
773
+ listenPort: 7845,
774
+ webDistDir: null,
775
+ version: NGINX_VERSION,
776
+ });
777
+ expect(onStarting).toHaveBeenCalledWith({
778
+ version: NGINX_VERSION,
779
+ webDistDir: null,
780
+ listenPort: 7845,
781
+ });
782
+
783
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
784
+ expect(conf).toBe(
785
+ buildIngressNginxConfig({
786
+ gatewayPort: 7830,
787
+ listenPort: 7845,
788
+ ipv6Loopback: hasIpv6Loopback(),
789
+ }),
790
+ );
791
+ expect(conf).toContain("location = /auth/token { return 404; }");
792
+ expect(conf).toContain("location = /v1/pair { return 404; }");
793
+ expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
794
+ expect(conf).not.toContain("__remote-index.html");
795
+ expect(conf).not.toContain("location ^~ /assistant/assets/");
796
+ expect(conf).not.toContain("alias ");
797
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
798
+ listenPort: 7845,
799
+ includeWebApp: false,
800
+ gatewayPort: 7830,
801
+ });
802
+ });
803
+
804
+ test("default mode serves the SPA config unchanged", async () => {
805
+ const ws = makeWorkspace();
806
+ mockNginxInstalled();
807
+ mockNginxSpawn();
808
+ mockWebDistPresent();
809
+
810
+ const result = await startRemoteWebIngress({
811
+ workspaceDir: ws,
812
+ gatewayPort: 7830,
813
+ listenPort: 7845,
814
+ });
815
+
816
+ if (result.status !== "started") {
817
+ throw new Error(`expected started, got ${result.status}`);
818
+ }
819
+ const { webDistDir } = result;
820
+ if (webDistDir === null) {
821
+ throw new Error("expected a web dist dir in SPA mode");
822
+ }
823
+
824
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
825
+ expect(conf).toBe(
826
+ buildIngressNginxConfig({
827
+ gatewayPort: 7830,
828
+ listenPort: 7845,
829
+ ipv6Loopback: hasIpv6Loopback(),
830
+ remoteWebIngress: {
831
+ webDistDir,
832
+ indexHtmlPath: join(ws, "data", "ingress", "assistant-index.html"),
833
+ hubUrl: PRODUCTION_HUB_URL,
834
+ },
835
+ }),
836
+ );
837
+ expect(conf).toContain("location ^~ /assistant/ {");
838
+ expect(conf).toContain("location ^~ /webhooks/ {");
839
+ const indexHtml = realFs.readFileSync(
840
+ join(ws, "data", "ingress", "assistant-index.html"),
841
+ "utf-8",
842
+ );
843
+ expect(indexHtml).toContain("remote-gateway");
844
+ expect(indexHtml).toContain(`"hubUrl":"${PRODUCTION_HUB_URL}"`);
845
+ // No assistant name was provided, so the served config omits the label.
846
+ expect(indexHtml).not.toContain("assistantName");
847
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
848
+ listenPort: 7845,
849
+ includeWebApp: true,
850
+ gatewayPort: 7830,
851
+ remoteWebConfigHash: spaConfigHash(),
852
+ });
853
+ });
854
+
855
+ test("stamps the assistant label into the served config when provided", async () => {
856
+ const ws = makeWorkspace();
857
+ mockNginxInstalled();
858
+ mockNginxSpawn();
859
+ mockWebDistPresent();
860
+
861
+ const result = await startRemoteWebIngress({
862
+ workspaceDir: ws,
863
+ gatewayPort: 7830,
864
+ listenPort: 7845,
865
+ assistantName: "My Homelab",
866
+ });
867
+
868
+ expect(result.status).toBe("started");
869
+ const indexHtml = realFs.readFileSync(
870
+ join(ws, "data", "ingress", "assistant-index.html"),
871
+ "utf-8",
872
+ );
873
+ expect(indexHtml).toContain('"assistantName":"My Homelab"');
874
+ expect(indexHtml).toContain(`"hubUrl":"${PRODUCTION_HUB_URL}"`);
875
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
876
+ expect(conf).toContain('\\"assistantName\\":\\"My Homelab\\"');
877
+ });
878
+
879
+ test("default mode still requires the web dist", async () => {
880
+ const ws = makeWorkspace();
881
+ mockNginxInstalled();
882
+ mockNginxSpawn();
883
+ mockWebDistMissing();
884
+
885
+ const onStarting = mock(() => {});
886
+ const result = await startRemoteWebIngress({
887
+ workspaceDir: ws,
888
+ gatewayPort: 7830,
889
+ listenPort: 7845,
890
+ onStarting,
891
+ });
892
+
893
+ expect(result).toEqual({ status: "web-dist-missing" });
894
+ expect(onStarting).not.toHaveBeenCalled();
895
+ expect(spawnMock).not.toHaveBeenCalled();
896
+ expect(realFs.existsSync(ingressConfPath(ws))).toBe(false);
897
+ });
898
+
899
+ test("short-circuits when the running edge already serves the requested mode", async () => {
900
+ const ws = makeWorkspace();
901
+ mockNginxInstalled();
902
+ mockNginxSpawn();
903
+ const pid = mockRunningEdge(ws, {
904
+ listenPort: 7845,
905
+ includeWebApp: false,
906
+ gatewayPort: 7830,
907
+ });
908
+ const edge = mockKillableNginx(pid);
909
+
910
+ const result = await startRemoteWebIngress({
911
+ workspaceDir: ws,
912
+ gatewayPort: 7830,
913
+ listenPort: 7845,
914
+ includeWebApp: false,
915
+ });
916
+
917
+ expect(result).toEqual({
918
+ status: "already-running",
919
+ listenPort: 7845,
920
+ includeWebApp: false,
921
+ gatewayPort: 7830,
922
+ });
923
+ expect(edge.killed()).toBe(false);
924
+ expect(spawnMock).not.toHaveBeenCalled();
925
+ });
926
+
927
+ test("already-running carries the recorded listen port over the requested one", async () => {
928
+ const ws = makeWorkspace();
929
+ mockNginxInstalled();
930
+ mockNginxSpawn();
931
+ const pid = mockRunningEdge(ws, {
932
+ listenPort: 7845,
933
+ includeWebApp: false,
934
+ gatewayPort: 7830,
935
+ });
936
+ const edge = mockKillableNginx(pid);
937
+
938
+ const result = await startRemoteWebIngress({
939
+ workspaceDir: ws,
940
+ gatewayPort: 7830,
941
+ listenPort: 7999,
942
+ includeWebApp: false,
943
+ });
944
+
945
+ expect(result).toEqual({
946
+ status: "already-running",
947
+ listenPort: 7845,
948
+ includeWebApp: false,
949
+ gatewayPort: 7830,
950
+ });
951
+ expect(edge.killed()).toBe(false);
952
+ expect(spawnMock).not.toHaveBeenCalled();
953
+ });
954
+
955
+ test("a failed restart reports the recorded mode and gateway port", async () => {
956
+ const ws = makeWorkspace();
957
+ mockNginxInstalled();
958
+ mockNginxSpawn();
959
+ mockWebDistMissing();
960
+ const pid = mockRunningEdge(ws, {
961
+ listenPort: 7845,
962
+ includeWebApp: true,
963
+ gatewayPort: 7900,
964
+ });
965
+ mockUnkillableNginx(pid);
966
+
967
+ const result = await startRemoteWebIngress({
968
+ workspaceDir: ws,
969
+ gatewayPort: 7830,
970
+ listenPort: 7845,
971
+ includeWebApp: false,
972
+ });
973
+
974
+ expect(result).toEqual({
975
+ status: "already-running",
976
+ listenPort: 7845,
977
+ includeWebApp: true,
978
+ gatewayPort: 7900,
979
+ });
980
+ expect(spawnMock).not.toHaveBeenCalled();
981
+ });
982
+
983
+ test("restarts the edge when its recorded gateway port drifts from the request", async () => {
984
+ const ws = makeWorkspace();
985
+ mockNginxInstalled();
986
+ mockNginxSpawn();
987
+ mockWebDistMissing();
988
+ const pid = mockRunningEdge(ws, {
989
+ listenPort: 7845,
990
+ includeWebApp: false,
991
+ gatewayPort: 7900,
992
+ });
993
+ const edge = mockKillableNginx(pid);
994
+
995
+ const result = await startRemoteWebIngress({
996
+ workspaceDir: ws,
997
+ gatewayPort: 7830,
998
+ listenPort: 7845,
999
+ includeWebApp: false,
1000
+ });
1001
+
1002
+ expect(result).toEqual({
1003
+ status: "started",
1004
+ listenPort: 7845,
1005
+ webDistDir: null,
1006
+ version: NGINX_VERSION,
1007
+ });
1008
+ expect(edge.killed()).toBe(true);
1009
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1010
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1011
+ expect(conf).toBe(
1012
+ buildIngressNginxConfig({
1013
+ gatewayPort: 7830,
1014
+ listenPort: 7845,
1015
+ ipv6Loopback: hasIpv6Loopback(),
1016
+ }),
1017
+ );
1018
+ expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
1019
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1020
+ listenPort: 7845,
1021
+ includeWebApp: false,
1022
+ gatewayPort: 7830,
1023
+ });
1024
+ });
1025
+
1026
+ test("restarts an edge whose state predates the recorded gateway port", async () => {
1027
+ const ws = makeWorkspace();
1028
+ mockNginxInstalled();
1029
+ mockNginxSpawn();
1030
+ mockWebDistMissing();
1031
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: false });
1032
+ const edge = mockKillableNginx(pid);
1033
+
1034
+ const result = await startRemoteWebIngress({
1035
+ workspaceDir: ws,
1036
+ gatewayPort: 7900,
1037
+ listenPort: 7845,
1038
+ includeWebApp: false,
1039
+ });
1040
+
1041
+ expect(result).toEqual({
1042
+ status: "started",
1043
+ listenPort: 7845,
1044
+ webDistDir: null,
1045
+ version: NGINX_VERSION,
1046
+ });
1047
+ expect(edge.killed()).toBe(true);
1048
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1049
+ expect(realFs.readFileSync(ingressConfPath(ws), "utf-8")).toBe(
1050
+ buildIngressNginxConfig({
1051
+ gatewayPort: 7900,
1052
+ listenPort: 7845,
1053
+ ipv6Loopback: hasIpv6Loopback(),
1054
+ }),
1055
+ );
1056
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1057
+ listenPort: 7845,
1058
+ includeWebApp: false,
1059
+ gatewayPort: 7900,
1060
+ });
1061
+ });
1062
+
1063
+ test("treats recorded state without a mode as an SPA edge", async () => {
1064
+ const ws = makeWorkspace();
1065
+ mockNginxInstalled();
1066
+ mockNginxSpawn();
1067
+ const pid = mockRunningEdge(ws, {
1068
+ listenPort: 7845,
1069
+ gatewayPort: 7830,
1070
+ remoteWebConfigHash: spaConfigHash(),
1071
+ });
1072
+ const edge = mockKillableNginx(pid);
1073
+
1074
+ const result = await startRemoteWebIngress({
1075
+ workspaceDir: ws,
1076
+ gatewayPort: 7830,
1077
+ listenPort: 7845,
1078
+ });
1079
+
1080
+ expect(result).toEqual({
1081
+ status: "already-running",
1082
+ listenPort: 7845,
1083
+ includeWebApp: true,
1084
+ gatewayPort: 7830,
1085
+ });
1086
+ expect(edge.killed()).toBe(false);
1087
+ expect(spawnMock).not.toHaveBeenCalled();
1088
+ });
1089
+
1090
+ test("short-circuits when the running SPA edge already serves the requested config", async () => {
1091
+ const ws = makeWorkspace();
1092
+ mockNginxInstalled();
1093
+ mockNginxSpawn();
1094
+ mockWebDistPresent();
1095
+ const pid = mockRunningEdge(ws, {
1096
+ listenPort: 7845,
1097
+ includeWebApp: true,
1098
+ gatewayPort: 7830,
1099
+ remoteWebConfigHash: spaConfigHash("My Homelab"),
1100
+ });
1101
+ const edge = mockKillableNginx(pid);
1102
+
1103
+ const result = await startRemoteWebIngress({
1104
+ workspaceDir: ws,
1105
+ gatewayPort: 7830,
1106
+ listenPort: 7845,
1107
+ assistantName: "My Homelab",
1108
+ });
1109
+
1110
+ expect(result).toEqual({
1111
+ status: "already-running",
1112
+ listenPort: 7845,
1113
+ includeWebApp: true,
1114
+ gatewayPort: 7830,
1115
+ });
1116
+ expect(edge.killed()).toBe(false);
1117
+ expect(spawnMock).not.toHaveBeenCalled();
1118
+ });
1119
+
1120
+ test("restarts the edge when the injected SPA config drifts from the recorded one", async () => {
1121
+ const ws = makeWorkspace();
1122
+ mockNginxInstalled();
1123
+ mockNginxSpawn();
1124
+ mockWebDistPresent();
1125
+ const pid = mockRunningEdge(ws, {
1126
+ listenPort: 7845,
1127
+ includeWebApp: true,
1128
+ gatewayPort: 7830,
1129
+ remoteWebConfigHash: spaConfigHash("Old Name"),
1130
+ });
1131
+ const edge = mockKillableNginx(pid);
1132
+
1133
+ const result = await startRemoteWebIngress({
1134
+ workspaceDir: ws,
1135
+ gatewayPort: 7830,
1136
+ listenPort: 7845,
1137
+ assistantName: "New Name",
1138
+ });
1139
+
1140
+ expect(result.status).toBe("started");
1141
+ expect(edge.killed()).toBe(true);
1142
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1143
+ const indexHtml = realFs.readFileSync(
1144
+ join(ws, "data", "ingress", "assistant-index.html"),
1145
+ "utf-8",
1146
+ );
1147
+ expect(indexHtml).toContain('"assistantName":"New Name"');
1148
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1149
+ listenPort: 7845,
1150
+ includeWebApp: true,
1151
+ gatewayPort: 7830,
1152
+ remoteWebConfigHash: spaConfigHash("New Name"),
1153
+ });
1154
+ });
1155
+
1156
+ test("restarts an SPA edge recorded against an older template version", async () => {
1157
+ const ws = makeWorkspace();
1158
+ mockNginxInstalled();
1159
+ mockNginxSpawn();
1160
+ mockWebDistPresent();
1161
+ // Same injected config, earlier template: the detached edge serves an
1162
+ // index this build renders differently, so identity must not match.
1163
+ const priorTemplateHash = createHash("sha256")
1164
+ .update(
1165
+ JSON.stringify({
1166
+ template: 1,
1167
+ config: {
1168
+ mode: "remote-gateway",
1169
+ apiBaseUrl: "/v1",
1170
+ platformDisabled: true,
1171
+ disablePlatform: true,
1172
+ hubUrl: PRODUCTION_HUB_URL,
1173
+ },
1174
+ }),
1175
+ )
1176
+ .digest("hex");
1177
+ const pid = mockRunningEdge(ws, {
1178
+ listenPort: 7845,
1179
+ includeWebApp: true,
1180
+ gatewayPort: 7830,
1181
+ remoteWebConfigHash: priorTemplateHash,
1182
+ });
1183
+ const edge = mockKillableNginx(pid);
1184
+
1185
+ const result = await startRemoteWebIngress({
1186
+ workspaceDir: ws,
1187
+ gatewayPort: 7830,
1188
+ listenPort: 7845,
1189
+ });
1190
+
1191
+ expect(result.status).toBe("started");
1192
+ expect(edge.killed()).toBe(true);
1193
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1194
+ listenPort: 7845,
1195
+ includeWebApp: true,
1196
+ gatewayPort: 7830,
1197
+ remoteWebConfigHash: spaConfigHash(),
1198
+ });
1199
+ });
1200
+
1201
+ test("restarts an SPA edge whose state predates the config fingerprint", async () => {
1202
+ const ws = makeWorkspace();
1203
+ mockNginxInstalled();
1204
+ mockNginxSpawn();
1205
+ mockWebDistPresent();
1206
+ const pid = mockRunningEdge(ws, {
1207
+ listenPort: 7845,
1208
+ includeWebApp: true,
1209
+ gatewayPort: 7830,
1210
+ });
1211
+ const edge = mockKillableNginx(pid);
1212
+
1213
+ const result = await startRemoteWebIngress({
1214
+ workspaceDir: ws,
1215
+ gatewayPort: 7830,
1216
+ listenPort: 7845,
1217
+ });
1218
+
1219
+ expect(result.status).toBe("started");
1220
+ expect(edge.killed()).toBe(true);
1221
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1222
+ listenPort: 7845,
1223
+ includeWebApp: true,
1224
+ gatewayPort: 7830,
1225
+ remoteWebConfigHash: spaConfigHash(),
1226
+ });
1227
+ });
1228
+
1229
+ test("a config-drifted edge that survives the restart attempt reports it as stale", async () => {
1230
+ const ws = makeWorkspace();
1231
+ mockNginxInstalled();
1232
+ mockNginxSpawn();
1233
+ mockWebDistPresent();
1234
+ const pid = mockRunningEdge(ws, {
1235
+ listenPort: 7845,
1236
+ includeWebApp: true,
1237
+ gatewayPort: 7830,
1238
+ remoteWebConfigHash: spaConfigHash("Old Name"),
1239
+ });
1240
+ mockUnkillableNginx(pid);
1241
+
1242
+ const result = await startRemoteWebIngress({
1243
+ workspaceDir: ws,
1244
+ gatewayPort: 7830,
1245
+ listenPort: 7845,
1246
+ assistantName: "New Name",
1247
+ });
1248
+
1249
+ expect(result).toEqual({
1250
+ status: "already-running",
1251
+ listenPort: 7845,
1252
+ includeWebApp: true,
1253
+ gatewayPort: 7830,
1254
+ staleRemoteWebConfig: true,
1255
+ });
1256
+ expect(spawnMock).not.toHaveBeenCalled();
1257
+ });
1258
+
1259
+ test("restarts an SPA edge when webhooks-only mode is requested", async () => {
1260
+ const ws = makeWorkspace();
1261
+ mockNginxInstalled();
1262
+ mockNginxSpawn();
1263
+ mockWebDistMissing();
1264
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: true });
1265
+ const edge = mockKillableNginx(pid);
1266
+
1267
+ const result = await startRemoteWebIngress({
1268
+ workspaceDir: ws,
1269
+ gatewayPort: 7830,
1270
+ listenPort: 7845,
1271
+ includeWebApp: false,
1272
+ });
1273
+
1274
+ expect(result).toEqual({
1275
+ status: "started",
1276
+ listenPort: 7845,
1277
+ webDistDir: null,
1278
+ version: NGINX_VERSION,
1279
+ });
1280
+ expect(edge.killed()).toBe(true);
1281
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1282
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1283
+ expect(conf).toBe(
1284
+ buildIngressNginxConfig({
1285
+ gatewayPort: 7830,
1286
+ listenPort: 7845,
1287
+ ipv6Loopback: hasIpv6Loopback(),
1288
+ }),
1289
+ );
1290
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1291
+ listenPort: 7845,
1292
+ includeWebApp: false,
1293
+ gatewayPort: 7830,
1294
+ });
1295
+ });
1296
+
1297
+ test("starts the requested mode when the old edge exits during the stop", async () => {
1298
+ const ws = makeWorkspace();
1299
+ mockNginxInstalled();
1300
+ mockNginxSpawn();
1301
+ mockWebDistMissing();
1302
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: true });
1303
+ // Alive for the initial running check, dead by the time the stop helper
1304
+ // probes it: the race where the old edge exits on its own mid-switch.
1305
+ let liveChecks = 0;
1306
+ process.kill = mock((targetPid: number, signal?: string | number) => {
1307
+ if (targetPid !== pid) return originalKill(targetPid, signal);
1308
+ if (signal === 0 && ++liveChecks === 1) return true;
1309
+ throw new Error("dead");
1310
+ }) as unknown as typeof process.kill;
1311
+
1312
+ const result = await startRemoteWebIngress({
1313
+ workspaceDir: ws,
1314
+ gatewayPort: 7830,
1315
+ listenPort: 7845,
1316
+ includeWebApp: false,
1317
+ });
1318
+
1319
+ expect(result).toEqual({
1320
+ status: "started",
1321
+ listenPort: 7845,
1322
+ webDistDir: null,
1323
+ version: NGINX_VERSION,
1324
+ });
1325
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1326
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1327
+ expect(conf).toBe(
1328
+ buildIngressNginxConfig({
1329
+ gatewayPort: 7830,
1330
+ listenPort: 7845,
1331
+ ipv6Loopback: hasIpv6Loopback(),
1332
+ }),
1333
+ );
1334
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1335
+ listenPort: 7845,
1336
+ includeWebApp: false,
1337
+ gatewayPort: 7830,
1338
+ });
1339
+ });
1340
+
1341
+ test("restarts a webhooks-only edge when SPA mode is requested", async () => {
1342
+ const ws = makeWorkspace();
1343
+ mockNginxInstalled();
1344
+ mockNginxSpawn();
1345
+ mockWebDistPresent();
1346
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: false });
1347
+ const edge = mockKillableNginx(pid);
1348
+
1349
+ const result = await startRemoteWebIngress({
1350
+ workspaceDir: ws,
1351
+ gatewayPort: 7830,
1352
+ listenPort: 7845,
1353
+ });
1354
+
1355
+ if (result.status !== "started") {
1356
+ throw new Error(`expected started, got ${result.status}`);
1357
+ }
1358
+ expect(result.webDistDir).not.toBeNull();
1359
+ expect(edge.killed()).toBe(true);
1360
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1361
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1362
+ expect(conf).toContain("location ^~ /assistant/ {");
1363
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1364
+ listenPort: 7845,
1365
+ includeWebApp: true,
1366
+ gatewayPort: 7830,
1367
+ remoteWebConfigHash: spaConfigHash(),
1368
+ });
1369
+ });
1370
+
1371
+ test("keeps a webhooks-only edge running when SPA mode is requested without a web dist", async () => {
1372
+ const ws = makeWorkspace();
1373
+ mockNginxInstalled();
1374
+ mockNginxSpawn();
1375
+ mockWebDistMissing();
1376
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: false });
1377
+ const edge = mockKillableNginx(pid);
1378
+
1379
+ const result = await startRemoteWebIngress({
1380
+ workspaceDir: ws,
1381
+ gatewayPort: 7830,
1382
+ listenPort: 7845,
1383
+ });
1384
+
1385
+ expect(result).toEqual({ status: "web-dist-missing" });
1386
+ expect(edge.killed()).toBe(false);
1387
+ expect(spawnMock).not.toHaveBeenCalled();
1388
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1389
+ listenPort: 7845,
1390
+ includeWebApp: false,
1391
+ });
1392
+ });
1393
+
1394
+ test("a spawn that exits despite a successful probe is a port conflict, not started", async () => {
1395
+ // Another assistant's edge on the same listen port answers the readiness
1396
+ // probe while this workspace's nginx dies on the bind, so a healthy probe
1397
+ // alone must not count as started.
1398
+ const ws = makeWorkspace();
1399
+ mockNginxInstalled();
1400
+ mockNginxSpawnExitsOnStartup();
1401
+ mockWebDistMissing();
1402
+ waitForDaemonReadyMock.mockImplementation(async () => true);
1403
+
1404
+ const result = await startRemoteWebIngress({
1405
+ workspaceDir: ws,
1406
+ gatewayPort: 7830,
1407
+ listenPort: 7845,
1408
+ includeWebApp: false,
1409
+ });
1410
+
1411
+ expect(result).toEqual({
1412
+ status: "port-conflict",
1413
+ listenPort: 7845,
1414
+ logPath: join(ws, "data", "logs", "nginx-ingress.log"),
1415
+ });
1416
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1417
+ const ingress = readConfig(ws).ingress as
1418
+ | Record<string, unknown>
1419
+ | undefined;
1420
+ expect(ingress?.nginx).toBeUndefined();
1421
+ });
1422
+
1423
+ test("a child that outlives the probe but never owns the port is a port conflict", async () => {
1424
+ // The healthy-squatter race: another workspace's edge answers the probe in
1425
+ // milliseconds, while our master is still failing its bind. The child being
1426
+ // alive right after the probe must not count as started.
1427
+ const ws = makeWorkspace();
1428
+ mockNginxInstalled();
1429
+ mockNginxSpawnExitsAfterProbe();
1430
+ mockWebDistMissing();
1431
+ waitForDaemonReadyMock.mockImplementation(async () => true);
1432
+
1433
+ const result = await startRemoteWebIngress({
1434
+ workspaceDir: ws,
1435
+ gatewayPort: 7830,
1436
+ listenPort: 7845,
1437
+ includeWebApp: false,
1438
+ });
1439
+
1440
+ expect(result).toEqual({
1441
+ status: "port-conflict",
1442
+ listenPort: 7845,
1443
+ logPath: join(ws, "data", "logs", "nginx-ingress.log"),
1444
+ });
1445
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1446
+ const ingress = readConfig(ws).ingress as
1447
+ | Record<string, unknown>
1448
+ | undefined;
1449
+ expect(ingress?.nginx).toBeUndefined();
1450
+ });
1451
+
1452
+ test("started requires the pid file to name the live spawned master", async () => {
1453
+ const ws = makeWorkspace();
1454
+ mockNginxInstalled();
1455
+ mockNginxSpawn();
1456
+ mockWebDistMissing();
1457
+
1458
+ const result = await startRemoteWebIngress({
1459
+ workspaceDir: ws,
1460
+ gatewayPort: 7830,
1461
+ listenPort: 7845,
1462
+ includeWebApp: false,
1463
+ });
1464
+
1465
+ expect(result).toEqual({
1466
+ status: "started",
1467
+ listenPort: 7845,
1468
+ webDistDir: null,
1469
+ version: NGINX_VERSION,
1470
+ });
1471
+ expect(
1472
+ realFs
1473
+ .readFileSync(join(ws, "data", "ingress", "nginx.pid"), "utf-8")
1474
+ .trim(),
1475
+ ).toBe(String(SPAWNED_NGINX_PID));
1476
+ // Ownership confirmed on the first settle iteration: exactly one ps
1477
+ // lookup, for the spawned master's pid, with no polling delay.
1478
+ expect(execFileSyncMock).toHaveBeenCalledTimes(1);
1479
+ expect(execFileSyncMock.mock.calls[0]?.[1]).toContain(
1480
+ String(SPAWNED_NGINX_PID),
1481
+ );
1482
+ });
1483
+
1484
+ test("a spawn that exits with a failed probe is also a port conflict", async () => {
1485
+ const ws = makeWorkspace();
1486
+ mockNginxInstalled();
1487
+ mockNginxSpawnExitsOnStartup();
1488
+ mockWebDistMissing();
1489
+ waitForDaemonReadyMock.mockImplementation(async () => false);
1490
+
1491
+ const result = await startRemoteWebIngress({
1492
+ workspaceDir: ws,
1493
+ gatewayPort: 7830,
1494
+ listenPort: 7845,
1495
+ includeWebApp: false,
1496
+ });
1497
+
1498
+ expect(result.status).toBe("port-conflict");
1499
+ });
1500
+
1501
+ test("a child still retrying its bind at the settle deadline is killed before rollback", async () => {
1502
+ const ws = makeWorkspace();
1503
+ mockNginxInstalled();
1504
+ mockWebDistMissing();
1505
+ const kills: string[] = [];
1506
+ // Alive for the whole settle window (nginx retrying its bind), never
1507
+ // writes a pid file; an orphan left running could win the bind later.
1508
+ spawnMock.mockImplementation((() => {
1509
+ return {
1510
+ unref: () => {},
1511
+ exitCode: null,
1512
+ pid: SPAWNED_NGINX_PID,
1513
+ once: () => {},
1514
+ kill: (signal: string) => {
1515
+ kills.push(signal);
1516
+ return true;
1517
+ },
1518
+ } as unknown as ChildProcess;
1519
+ }) as unknown as typeof childProcess.spawn);
1520
+
1521
+ const result = await startRemoteWebIngress({
1522
+ workspaceDir: ws,
1523
+ gatewayPort: 7830,
1524
+ listenPort: 7845,
1525
+ includeWebApp: false,
1526
+ });
1527
+
1528
+ expect(result.status).toBe("port-conflict");
1529
+ expect(kills).toEqual(["SIGTERM"]);
1530
+ }, 10_000);
1531
+ });
1532
+
1533
+ describe("ensureTunnelEdge", () => {
1534
+ const ASSISTANT_ID = "assistant-1";
1535
+ const REQUESTED_PORT = 7846;
1536
+ const originalIngressPort = process.env.VELLUM_NGINX_INGRESS_PORT;
1537
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
1538
+ let lockfileDir: string;
1539
+
1540
+ pinProductionEnvironment();
1541
+
1542
+ beforeEach(() => {
1543
+ process.env.VELLUM_NGINX_INGRESS_PORT = String(REQUESTED_PORT);
1544
+ // Isolated lockfile dir so the assistant-name lookup never reads a real
1545
+ // machine lockfile.
1546
+ lockfileDir = makeWorkspace();
1547
+ process.env.VELLUM_LOCKFILE_DIR = lockfileDir;
1548
+ });
1549
+
1550
+ afterEach(() => {
1551
+ if (originalIngressPort === undefined) {
1552
+ delete process.env.VELLUM_NGINX_INGRESS_PORT;
1553
+ } else {
1554
+ process.env.VELLUM_NGINX_INGRESS_PORT = originalIngressPort;
1555
+ }
1556
+ if (originalLockfileDir === undefined) {
1557
+ delete process.env.VELLUM_LOCKFILE_DIR;
1558
+ } else {
1559
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
1560
+ }
1561
+ });
1562
+
1563
+ test("threads the lockfile display name into the served SPA config", async () => {
1564
+ const ws = makeWorkspace();
1565
+ mockNginxInstalled();
1566
+ mockNginxSpawn();
1567
+ mockWebDistPresent();
1568
+ writeFileSync(
1569
+ join(lockfileDir, ".vellum.lock.json"),
1570
+ JSON.stringify({
1571
+ assistants: [
1572
+ {
1573
+ assistantId: ASSISTANT_ID,
1574
+ name: "Homelab",
1575
+ cloud: "local",
1576
+ runtimeUrl: "http://127.0.0.1:7830",
1577
+ localUrl: "http://127.0.0.1:7830",
1578
+ },
1579
+ ],
1580
+ activeAssistant: ASSISTANT_ID,
1581
+ }),
1582
+ );
1583
+
1584
+ const result = await ensureTunnelEdge({
1585
+ assistantId: ASSISTANT_ID,
1586
+ workspaceDir: ws,
1587
+ gatewayPort: 7830,
1588
+ });
1589
+
1590
+ expect(result.includesWebApp).toBe(true);
1591
+ const indexHtml = realFs.readFileSync(
1592
+ join(ws, "data", "ingress", "assistant-index.html"),
1593
+ "utf-8",
1594
+ );
1595
+ expect(indexHtml).toContain('"assistantName":"Homelab"');
1596
+ expect(indexHtml).toContain(`"hubUrl":"${PRODUCTION_HUB_URL}"`);
1597
+ });
1598
+
1599
+ test("omits the assistant label when the lockfile has no matching entry", async () => {
1600
+ const ws = makeWorkspace();
1601
+ mockNginxInstalled();
1602
+ mockNginxSpawn();
1603
+ mockWebDistPresent();
1604
+
1605
+ const result = await ensureTunnelEdge({
1606
+ assistantId: ASSISTANT_ID,
1607
+ workspaceDir: ws,
1608
+ gatewayPort: 7830,
1609
+ });
1610
+
1611
+ expect(result.includesWebApp).toBe(true);
1612
+ const indexHtml = realFs.readFileSync(
1613
+ join(ws, "data", "ingress", "assistant-index.html"),
1614
+ "utf-8",
1615
+ );
1616
+ expect(indexHtml).not.toContain("assistantName");
1617
+ expect(indexHtml).toContain(`"hubUrl":"${PRODUCTION_HUB_URL}"`);
1618
+ });
1619
+
1620
+ test("reuses a running edge that matches the flag-resolved mode", async () => {
1621
+ const ws = makeWorkspace();
1622
+ mockNginxInstalled();
1623
+ mockNginxSpawn();
1624
+ const pid = mockRunningEdge(ws, {
1625
+ listenPort: 7845,
1626
+ includeWebApp: true,
1627
+ gatewayPort: 7830,
1628
+ remoteWebConfigHash: spaConfigHash(),
1629
+ });
1630
+ const edge = mockKillableNginx(pid);
1631
+
1632
+ const result = await ensureTunnelEdge({
1633
+ assistantId: ASSISTANT_ID,
1634
+ workspaceDir: ws,
1635
+ gatewayPort: 7830,
1636
+ });
1637
+
1638
+ expect(result).toEqual({
1639
+ port: 7845,
1640
+ started: false,
1641
+ includesWebApp: true,
1642
+ });
1643
+ expect(edge.killed()).toBe(false);
1644
+ expect(spawnMock).not.toHaveBeenCalled();
1645
+ });
1646
+
1647
+ test("reuses a running webhooks-only edge and reports the recorded mode", async () => {
1648
+ const ws = makeWorkspace();
1649
+ mockNginxInstalled();
1650
+ mockNginxSpawn();
1651
+ mockWebDistMissing();
1652
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1653
+ const pid = mockRunningEdge(ws, {
1654
+ listenPort: 7845,
1655
+ includeWebApp: false,
1656
+ gatewayPort: 7830,
1657
+ });
1658
+ const edge = mockKillableNginx(pid);
1659
+
1660
+ const result = await ensureTunnelEdge({
1661
+ assistantId: ASSISTANT_ID,
1662
+ workspaceDir: ws,
1663
+ gatewayPort: 7830,
1664
+ });
1665
+
1666
+ expect(result).toEqual({
1667
+ port: 7845,
1668
+ started: false,
1669
+ includesWebApp: false,
1670
+ });
1671
+ expect(edge.killed()).toBe(false);
1672
+ expect(spawnMock).not.toHaveBeenCalled();
1673
+ });
1674
+
1675
+ test("restarts the edge when the lockfile display name changes", async () => {
1676
+ const ws = makeWorkspace();
1677
+ mockNginxInstalled();
1678
+ mockNginxSpawn();
1679
+ mockWebDistPresent();
1680
+ const writeLockfile = (name: string) =>
1681
+ writeFileSync(
1682
+ join(lockfileDir, ".vellum.lock.json"),
1683
+ JSON.stringify({
1684
+ assistants: [
1685
+ {
1686
+ assistantId: ASSISTANT_ID,
1687
+ name,
1688
+ cloud: "local",
1689
+ runtimeUrl: "http://127.0.0.1:7830",
1690
+ localUrl: "http://127.0.0.1:7830",
1691
+ },
1692
+ ],
1693
+ activeAssistant: ASSISTANT_ID,
1694
+ }),
1695
+ );
1696
+ writeLockfile("Homelab");
1697
+
1698
+ const first = await ensureTunnelEdge({
1699
+ assistantId: ASSISTANT_ID,
1700
+ workspaceDir: ws,
1701
+ gatewayPort: 7830,
1702
+ });
1703
+ expect(first.started).toBe(true);
1704
+
1705
+ // Same port and mode, renamed assistant: the edge must restart so the
1706
+ // served index carries the new label instead of reporting already-running.
1707
+ writeLockfile("Renamed");
1708
+ const second = await ensureTunnelEdge({
1709
+ assistantId: ASSISTANT_ID,
1710
+ workspaceDir: ws,
1711
+ gatewayPort: 7830,
1712
+ });
1713
+
1714
+ expect(second.started).toBe(true);
1715
+ expect(spawnMock).toHaveBeenCalledTimes(2);
1716
+ const indexHtml = realFs.readFileSync(
1717
+ join(ws, "data", "ingress", "assistant-index.html"),
1718
+ "utf-8",
1719
+ );
1720
+ expect(indexHtml).toContain('"assistantName":"Renamed"');
1721
+ expect(indexHtml).not.toContain('"assistantName":"Homelab"');
1722
+ });
1723
+
1724
+ test("a config-drifted edge that survives the restart attempt throws", async () => {
1725
+ const ws = makeWorkspace();
1726
+ mockNginxInstalled();
1727
+ mockNginxSpawn();
1728
+ mockWebDistPresent();
1729
+ const pid = mockRunningEdge(ws, {
1730
+ listenPort: REQUESTED_PORT,
1731
+ includeWebApp: true,
1732
+ gatewayPort: 7830,
1733
+ remoteWebConfigHash: spaConfigHash("Stale Name"),
1734
+ });
1735
+ mockUnkillableNginx(pid);
1736
+
1737
+ const promise = ensureTunnelEdge({
1738
+ assistantId: ASSISTANT_ID,
1739
+ workspaceDir: ws,
1740
+ gatewayPort: 7830,
1741
+ });
1742
+
1743
+ await expect(promise).rejects.toThrow(
1744
+ "still serving an outdated remote web config",
1745
+ );
1746
+ await expect(promise).rejects.toThrow("vellum nginx-ingress down");
1747
+ expect(spawnMock).not.toHaveBeenCalled();
1748
+ });
1749
+
1750
+ test("a mode-drifted edge that survives the restart attempt throws", async () => {
1751
+ const ws = makeWorkspace();
1752
+ mockNginxInstalled();
1753
+ mockNginxSpawn();
1754
+ mockWebDistMissing();
1755
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1756
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: true });
1757
+ mockUnkillableNginx(pid);
1758
+
1759
+ const promise = ensureTunnelEdge({
1760
+ assistantId: ASSISTANT_ID,
1761
+ workspaceDir: ws,
1762
+ gatewayPort: 7830,
1763
+ });
1764
+
1765
+ await expect(promise).rejects.toThrow(
1766
+ "still running in web app mode and could not be restarted in webhooks-only mode",
1767
+ );
1768
+ await expect(promise).rejects.toThrow("vellum nginx-ingress down");
1769
+ expect(spawnMock).not.toHaveBeenCalled();
1770
+ });
1771
+
1772
+ test("a port-drifted edge that survives the restart attempt throws", async () => {
1773
+ const ws = makeWorkspace();
1774
+ mockNginxInstalled();
1775
+ mockNginxSpawn();
1776
+ mockWebDistMissing();
1777
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1778
+ const pid = mockRunningEdge(ws, {
1779
+ listenPort: 7845,
1780
+ includeWebApp: false,
1781
+ gatewayPort: 7900,
1782
+ });
1783
+ mockUnkillableNginx(pid);
1784
+
1785
+ const promise = ensureTunnelEdge({
1786
+ assistantId: ASSISTANT_ID,
1787
+ workspaceDir: ws,
1788
+ gatewayPort: 7830,
1789
+ });
1790
+
1791
+ await expect(promise).rejects.toThrow(
1792
+ "still proxying gateway port 7900 and could not be restarted against port 7830",
1793
+ );
1794
+ await expect(promise).rejects.toThrow("vellum nginx-ingress down");
1795
+ expect(spawnMock).not.toHaveBeenCalled();
1796
+ });
1797
+
1798
+ test("restarts a port-drifted edge against the requested gateway port", async () => {
1799
+ const ws = makeWorkspace();
1800
+ mockNginxInstalled();
1801
+ mockNginxSpawn();
1802
+ mockWebDistMissing();
1803
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1804
+ const pid = mockRunningEdge(ws, {
1805
+ listenPort: 7845,
1806
+ includeWebApp: false,
1807
+ gatewayPort: 7900,
1808
+ });
1809
+ const edge = mockKillableNginx(pid);
1810
+
1811
+ const result = await ensureTunnelEdge({
1812
+ assistantId: ASSISTANT_ID,
1813
+ workspaceDir: ws,
1814
+ gatewayPort: 7830,
1815
+ });
1816
+
1817
+ expect(result).toEqual({
1818
+ port: REQUESTED_PORT,
1819
+ started: true,
1820
+ includesWebApp: false,
1821
+ });
1822
+ expect(edge.killed()).toBe(true);
1823
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1824
+ expect(conf).toBe(
1825
+ buildIngressNginxConfig({
1826
+ gatewayPort: 7830,
1827
+ listenPort: REQUESTED_PORT,
1828
+ ipv6Loopback: hasIpv6Loopback(),
1829
+ }),
1830
+ );
1831
+ });
1832
+
1833
+ test("restarts a mode-drifted edge into the flag-resolved mode", async () => {
1834
+ const ws = makeWorkspace();
1835
+ mockNginxInstalled();
1836
+ mockNginxSpawn();
1837
+ mockWebDistMissing();
1838
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1839
+ const pid = mockRunningEdge(ws, { listenPort: 7845, includeWebApp: true });
1840
+ const edge = mockKillableNginx(pid);
1841
+
1842
+ const result = await ensureTunnelEdge({
1843
+ assistantId: ASSISTANT_ID,
1844
+ workspaceDir: ws,
1845
+ gatewayPort: 7830,
1846
+ });
1847
+
1848
+ expect(result).toEqual({
1849
+ port: REQUESTED_PORT,
1850
+ started: true,
1851
+ includesWebApp: false,
1852
+ });
1853
+ expect(edge.killed()).toBe(true);
1854
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1855
+ expect(conf).toBe(
1856
+ buildIngressNginxConfig({
1857
+ gatewayPort: 7830,
1858
+ listenPort: REQUESTED_PORT,
1859
+ ipv6Loopback: hasIpv6Loopback(),
1860
+ }),
1861
+ );
1862
+ });
1863
+
1864
+ test("flag enabled starts the SPA edge", async () => {
1865
+ const ws = makeWorkspace();
1866
+ mockNginxInstalled();
1867
+ mockNginxSpawn();
1868
+ mockWebDistPresent();
1869
+
1870
+ const result = await ensureTunnelEdge({
1871
+ assistantId: ASSISTANT_ID,
1872
+ workspaceDir: ws,
1873
+ gatewayPort: 7830,
1874
+ });
1875
+
1876
+ expect(result).toEqual({
1877
+ port: REQUESTED_PORT,
1878
+ started: true,
1879
+ includesWebApp: true,
1880
+ });
1881
+ expect(isFeatureFlagEnabledMock).toHaveBeenCalledWith(
1882
+ ASSISTANT_ID,
1883
+ featureFlags.WEB_REMOTE_INGRESS_FLAG,
1884
+ { runtimeUrl: "http://127.0.0.1:7830" },
1885
+ );
1886
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1887
+ expect(conf).toContain("location ^~ /assistant/ {");
1888
+ expect(conf).toContain("location ^~ /webhooks/ {");
1889
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1890
+ listenPort: REQUESTED_PORT,
1891
+ includeWebApp: true,
1892
+ gatewayPort: 7830,
1893
+ remoteWebConfigHash: spaConfigHash(),
1894
+ });
1895
+ });
1896
+
1897
+ test("flag disabled starts the webhooks-only edge", async () => {
1898
+ const ws = makeWorkspace();
1899
+ mockNginxInstalled();
1900
+ mockNginxSpawn();
1901
+ mockWebDistMissing();
1902
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1903
+
1904
+ const result = await ensureTunnelEdge({
1905
+ assistantId: ASSISTANT_ID,
1906
+ workspaceDir: ws,
1907
+ gatewayPort: 7830,
1908
+ });
1909
+
1910
+ expect(result).toEqual({
1911
+ port: REQUESTED_PORT,
1912
+ started: true,
1913
+ includesWebApp: false,
1914
+ });
1915
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1916
+ expect(conf).toContain("location = /v1/pair { return 404; }");
1917
+ expect(conf).not.toContain("location ^~ /assistant/ {");
1918
+ expect((readConfig(ws).ingress as Record<string, unknown>).nginx).toEqual({
1919
+ listenPort: REQUESTED_PORT,
1920
+ includeWebApp: false,
1921
+ gatewayPort: 7830,
1922
+ });
1923
+ });
1924
+
1925
+ test("forwards onStarting so callers can print progress", async () => {
1926
+ const ws = makeWorkspace();
1927
+ mockNginxInstalled();
1928
+ mockNginxSpawn();
1929
+ mockWebDistMissing();
1930
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
1931
+ const onStarting = mock(
1932
+ (_info: {
1933
+ version: string;
1934
+ webDistDir: string | null;
1935
+ listenPort: number;
1936
+ }) => {},
1937
+ );
1938
+
1939
+ await ensureTunnelEdge({
1940
+ assistantId: ASSISTANT_ID,
1941
+ workspaceDir: ws,
1942
+ gatewayPort: 7830,
1943
+ onStarting,
1944
+ });
1945
+
1946
+ expect(onStarting).toHaveBeenCalledWith({
1947
+ version: NGINX_VERSION,
1948
+ webDistDir: null,
1949
+ listenPort: REQUESTED_PORT,
1950
+ });
1951
+ });
1952
+
1953
+ test("an entry without an assistant id gets the webhooks-only edge", async () => {
1954
+ const ws = makeWorkspace();
1955
+ mockNginxInstalled();
1956
+ mockNginxSpawn();
1957
+ mockWebDistMissing();
1958
+
1959
+ const result = await ensureTunnelEdge({
1960
+ assistantId: undefined,
1961
+ workspaceDir: ws,
1962
+ gatewayPort: 7830,
1963
+ });
1964
+
1965
+ expect(result).toEqual({
1966
+ port: REQUESTED_PORT,
1967
+ started: true,
1968
+ includesWebApp: false,
1969
+ });
1970
+ expect(isFeatureFlagEnabledMock).not.toHaveBeenCalled();
1971
+ const conf = realFs.readFileSync(ingressConfPath(ws), "utf-8");
1972
+ expect(conf).not.toContain("location ^~ /assistant/ {");
1973
+ });
1974
+
1975
+ test("missing nginx throws with install instructions", async () => {
1976
+ const ws = makeWorkspace();
1977
+ mockNginxMissing();
1978
+
1979
+ const promise = ensureTunnelEdge({
1980
+ assistantId: ASSISTANT_ID,
1981
+ workspaceDir: ws,
1982
+ gatewayPort: 7830,
1983
+ });
1984
+
1985
+ await expect(promise).rejects.toThrow("brew install nginx");
1986
+ await expect(promise).rejects.toThrow("apt install nginx");
1987
+ await expect(promise).rejects.toThrow("NGINX_BIN");
1988
+ expect(spawnMock).not.toHaveBeenCalled();
1989
+ });
1990
+
1991
+ test("flag lookup failure throws the wake hint", async () => {
1992
+ const ws = makeWorkspace();
1993
+ mockNginxInstalled();
1994
+ isFeatureFlagEnabledMock.mockImplementation(async () => {
1995
+ throw new Error("connect ECONNREFUSED");
1996
+ });
1997
+
1998
+ const promise = ensureTunnelEdge({
1999
+ assistantId: ASSISTANT_ID,
2000
+ workspaceDir: ws,
2001
+ gatewayPort: 7830,
2002
+ });
2003
+
2004
+ await expect(promise).rejects.toThrow(
2005
+ "Could not verify the `web-remote-ingress` feature flag",
2006
+ );
2007
+ await expect(promise).rejects.toThrow("Try `vellum wake` and retry");
2008
+ await expect(promise).rejects.toThrow("connect ECONNREFUSED");
2009
+ expect(spawnMock).not.toHaveBeenCalled();
2010
+ });
2011
+
2012
+ test("flagRetry retries a thrown flag lookup and then starts the edge", async () => {
2013
+ const ws = makeWorkspace();
2014
+ mockNginxInstalled();
2015
+ mockNginxSpawn();
2016
+ mockWebDistMissing();
2017
+ isFeatureFlagEnabledMock
2018
+ .mockImplementationOnce(async () => {
2019
+ throw new Error('HTTP 503 {"status":"starting"}');
2020
+ })
2021
+ .mockImplementationOnce(async () => {
2022
+ throw new Error('HTTP 503 {"status":"starting"}');
2023
+ })
2024
+ .mockImplementationOnce(async () => false);
2025
+
2026
+ const result = await ensureTunnelEdge({
2027
+ assistantId: ASSISTANT_ID,
2028
+ workspaceDir: ws,
2029
+ gatewayPort: 7830,
2030
+ flagRetry: { attempts: 3, intervalMs: 1 },
2031
+ });
2032
+
2033
+ expect(isFeatureFlagEnabledMock).toHaveBeenCalledTimes(3);
2034
+ expect(result).toEqual({
2035
+ port: REQUESTED_PORT,
2036
+ started: true,
2037
+ includesWebApp: false,
2038
+ });
2039
+ });
2040
+
2041
+ test("flagRetry does not retry a resolved false: it is a real answer", async () => {
2042
+ const ws = makeWorkspace();
2043
+ mockNginxInstalled();
2044
+ mockNginxSpawn();
2045
+ mockWebDistMissing();
2046
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
2047
+
2048
+ const result = await ensureTunnelEdge({
2049
+ assistantId: ASSISTANT_ID,
2050
+ workspaceDir: ws,
2051
+ gatewayPort: 7830,
2052
+ flagRetry: { attempts: 3, intervalMs: 1 },
2053
+ });
2054
+
2055
+ expect(isFeatureFlagEnabledMock).toHaveBeenCalledTimes(1);
2056
+ expect(result.includesWebApp).toBe(false);
2057
+ });
2058
+
2059
+ test("an exhausted flagRetry throws the wake hint with the last error", async () => {
2060
+ const ws = makeWorkspace();
2061
+ mockNginxInstalled();
2062
+ isFeatureFlagEnabledMock.mockImplementation(async () => {
2063
+ throw new Error("connect ECONNREFUSED");
2064
+ });
2065
+
2066
+ const promise = ensureTunnelEdge({
2067
+ assistantId: ASSISTANT_ID,
2068
+ workspaceDir: ws,
2069
+ gatewayPort: 7830,
2070
+ flagRetry: { attempts: 3, intervalMs: 1 },
2071
+ });
2072
+
2073
+ await expect(promise).rejects.toThrow(
2074
+ "Could not verify the `web-remote-ingress` feature flag",
2075
+ );
2076
+ await expect(promise).rejects.toThrow("connect ECONNREFUSED");
2077
+ expect(isFeatureFlagEnabledMock).toHaveBeenCalledTimes(3);
2078
+ expect(spawnMock).not.toHaveBeenCalled();
2079
+ });
2080
+
2081
+ test("missing web dist with the flag enabled throws build guidance", async () => {
2082
+ const ws = makeWorkspace();
2083
+ mockNginxInstalled();
2084
+ mockWebDistMissing();
2085
+
2086
+ await expect(
2087
+ ensureTunnelEdge({
2088
+ assistantId: ASSISTANT_ID,
2089
+ workspaceDir: ws,
2090
+ gatewayPort: 7830,
2091
+ }),
2092
+ ).rejects.toThrow("@vellumai/web");
2093
+ });
2094
+
2095
+ test("an unreachable edge throws with the log path", async () => {
2096
+ const ws = makeWorkspace();
2097
+ mockNginxInstalled();
2098
+ mockNginxSpawn();
2099
+ mockWebDistMissing();
2100
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
2101
+ waitForDaemonReadyMock.mockImplementation(async () => false);
2102
+
2103
+ await expect(
2104
+ ensureTunnelEdge({
2105
+ assistantId: ASSISTANT_ID,
2106
+ workspaceDir: ws,
2107
+ gatewayPort: 7830,
2108
+ }),
2109
+ ).rejects.toThrow(join(ws, "data", "logs", "nginx-ingress.log"));
2110
+ });
2111
+
2112
+ test("a port conflict throws naming the port and the override mechanism", async () => {
2113
+ const ws = makeWorkspace();
2114
+ mockNginxInstalled();
2115
+ mockNginxSpawnExitsOnStartup();
2116
+ mockWebDistMissing();
2117
+ isFeatureFlagEnabledMock.mockImplementation(async () => false);
2118
+ waitForDaemonReadyMock.mockImplementation(async () => true);
2119
+
2120
+ const promise = ensureTunnelEdge({
2121
+ assistantId: ASSISTANT_ID,
2122
+ workspaceDir: ws,
2123
+ gatewayPort: 7830,
2124
+ });
2125
+
2126
+ await expect(promise).rejects.toThrow(
2127
+ `already in use (for example by another assistant's tunnel edge)`,
2128
+ );
2129
+ await expect(promise).rejects.toThrow(`127.0.0.1:${REQUESTED_PORT}`);
2130
+ await expect(promise).rejects.toThrow("VELLUM_NGINX_INGRESS_PORT");
2131
+ await expect(promise).rejects.toThrow(
2132
+ join(ws, "data", "logs", "nginx-ingress.log"),
2133
+ );
2134
+ });
2135
+ });