@vellumai/cli 0.8.12-staging.2 → 0.9.0-staging.1

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 (51) hide show
  1. package/bun.lock +49 -56
  2. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  4. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  7. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  8. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  9. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  10. package/package.json +3 -3
  11. package/src/__tests__/assistant-config.test.ts +1 -2
  12. package/src/__tests__/device-id.test.ts +6 -14
  13. package/src/__tests__/helpers/os-mock.ts +27 -0
  14. package/src/__tests__/login-loopback.test.ts +71 -0
  15. package/src/__tests__/multi-local.test.ts +2 -10
  16. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  17. package/src/__tests__/nginx-ingress.test.ts +401 -0
  18. package/src/__tests__/sleep.test.ts +4 -0
  19. package/src/__tests__/teleport.test.ts +6 -9
  20. package/src/__tests__/tunnel.test.ts +164 -0
  21. package/src/__tests__/wake.test.ts +15 -4
  22. package/src/__tests__/workos-pkce.test.ts +314 -0
  23. package/src/commands/flags.ts +1 -22
  24. package/src/commands/hatch.ts +90 -9
  25. package/src/commands/login.ts +123 -59
  26. package/src/commands/nginx-ingress.ts +291 -0
  27. package/src/commands/rollback.ts +0 -6
  28. package/src/commands/sleep.ts +17 -0
  29. package/src/commands/teleport.ts +23 -36
  30. package/src/commands/tunnel.ts +69 -11
  31. package/src/commands/upgrade.ts +0 -2
  32. package/src/commands/wake.ts +7 -5
  33. package/src/commands/workflows.ts +301 -0
  34. package/src/index.ts +8 -0
  35. package/src/lib/arg-utils.ts +48 -0
  36. package/src/lib/assistant-client.ts +2 -0
  37. package/src/lib/assistant-config.ts +0 -7
  38. package/src/lib/cloudflare-tunnel.ts +15 -2
  39. package/src/lib/docker.ts +103 -49
  40. package/src/lib/feature-flags.test.ts +157 -0
  41. package/src/lib/feature-flags.ts +38 -0
  42. package/src/lib/hatch-local.ts +0 -1
  43. package/src/lib/local.ts +5 -0
  44. package/src/lib/nginx-ingress.ts +574 -0
  45. package/src/lib/ngrok.ts +26 -4
  46. package/src/lib/platform-client.ts +0 -1
  47. package/src/lib/retire-local.ts +5 -0
  48. package/src/lib/statefulset.ts +73 -21
  49. package/src/lib/sync-cloud-assistants.ts +4 -17
  50. package/src/lib/upgrade-lifecycle.ts +1 -2
  51. package/src/lib/workos-pkce.ts +160 -0
@@ -10,16 +10,7 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
10
10
 
11
11
  // Mock homedir() to return testDir — this isolates allocateLocalResources()
12
12
  // which uses homedir() directly for instance directory creation.
13
- const realOs = await import("node:os");
14
- mock.module("node:os", () => ({
15
- ...realOs,
16
- homedir: () => testDir,
17
- }));
18
- // Also mock the bare "os" specifier since assistant-config.ts uses `from "os"`
19
- mock.module("os", () => ({
20
- ...realOs,
21
- homedir: () => testDir,
22
- }));
13
+ await mockOsHomedir(() => () => testDir);
23
14
 
24
15
  // Mock probePort so we control which ports appear in-use without touching the network
25
16
  const probePortMock = mock<(port: number, host?: string) => Promise<boolean>>(
@@ -43,6 +34,7 @@ import {
43
34
  DEFAULT_GATEWAY_PORT,
44
35
  DEFAULT_QDRANT_PORT,
45
36
  } from "../lib/constants.js";
37
+ import { mockOsHomedir } from "./helpers/os-mock.js";
46
38
 
47
39
  afterAll(() => {
48
40
  rmSync(testDir, { recursive: true, force: true });
@@ -0,0 +1,69 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { resolveNginxIngressTarget } from "../commands/nginx-ingress.js";
7
+ import type { AssistantEntry } from "../lib/assistant-config.js";
8
+
9
+ const testDir = mkdtempSync(join(tmpdir(), "cli-nginx-ingress-command-test-"));
10
+ const workspaceDir = join(testDir, "workspace");
11
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
12
+ const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
13
+
14
+ function writeLockfile(
15
+ entries: AssistantEntry[],
16
+ activeAssistant?: string,
17
+ ): void {
18
+ mkdirSync(testDir, { recursive: true });
19
+ writeFileSync(
20
+ join(testDir, ".vellum.lock.json"),
21
+ JSON.stringify(
22
+ {
23
+ assistants: entries,
24
+ ...(activeAssistant ? { activeAssistant } : {}),
25
+ },
26
+ null,
27
+ 2,
28
+ ),
29
+ );
30
+ }
31
+
32
+ describe("resolveNginxIngressTarget", () => {
33
+ beforeEach(() => {
34
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
35
+ process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
36
+ rmSync(join(testDir, ".vellum.lock.json"), { force: true });
37
+ });
38
+
39
+ afterAll(() => {
40
+ if (originalLockfileDir === undefined) {
41
+ delete process.env.VELLUM_LOCKFILE_DIR;
42
+ } else {
43
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
44
+ }
45
+ if (originalWorkspaceDir === undefined) {
46
+ delete process.env.VELLUM_WORKSPACE_DIR;
47
+ } else {
48
+ process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
49
+ }
50
+ rmSync(testDir, { recursive: true, force: true });
51
+ });
52
+
53
+ test("derives the gateway port from runtimeUrl when resources are absent", () => {
54
+ writeLockfile([
55
+ {
56
+ assistantId: "docker-assistant",
57
+ name: "Docker Assistant",
58
+ runtimeUrl: "http://localhost:9123",
59
+ cloud: "docker",
60
+ },
61
+ ]);
62
+
63
+ expect(resolveNginxIngressTarget("Docker Assistant")).toEqual({
64
+ assistantId: "docker-assistant",
65
+ workspaceDir,
66
+ gatewayPort: 9123,
67
+ });
68
+ });
69
+ });
@@ -0,0 +1,401 @@
1
+ import * as childProcess from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { afterEach, describe, expect, mock, test } from "bun:test";
14
+
15
+ const execFileSyncMock = mock(childProcess.execFileSync);
16
+
17
+ mock.module("node:child_process", () => ({
18
+ ...childProcess,
19
+ execFileSync: execFileSyncMock,
20
+ }));
21
+
22
+ import {
23
+ buildIngressNginxConfig,
24
+ buildRemoteWebIndexHtml,
25
+ resolveTunnelTargetPort,
26
+ stopIngressNginx,
27
+ } from "../lib/nginx-ingress.js";
28
+
29
+ const originalKill = process.kill;
30
+
31
+ describe("buildIngressNginxConfig", () => {
32
+ const conf = buildIngressNginxConfig({ gatewayPort: 7830, listenPort: 7840 });
33
+ const remoteConf = buildIngressNginxConfig({
34
+ gatewayPort: 7830,
35
+ listenPort: 7840,
36
+ remoteWebIngress: {
37
+ webDistDir: "/tmp/vellum web/dist",
38
+ config: {
39
+ mode: "remote-gateway",
40
+ apiBaseUrl: "/v1",
41
+ platformDisabled: true,
42
+ },
43
+ },
44
+ });
45
+
46
+ test("listens on loopback only", () => {
47
+ expect(conf).toContain("listen 127.0.0.1:7840;");
48
+ const listens = conf.match(/listen [^;]+;/g) ?? [];
49
+ expect(listens.length).toBeGreaterThan(0);
50
+ for (const directive of listens) {
51
+ expect(directive).toContain("127.0.0.1");
52
+ }
53
+ });
54
+
55
+ test("proxies requests to the gateway", () => {
56
+ expect(conf).toContain("location / {");
57
+ expect(conf).toContain("proxy_pass http://127.0.0.1:7830;");
58
+ expect(conf).toContain('proxy_set_header X-Vellum-Edge-Forwarded "1";');
59
+ expect(conf).not.toContain("return 404;");
60
+ expect(conf).not.toContain("return 403;");
61
+ expect(conf).not.toContain("location =");
62
+ expect(conf).not.toContain("location ~");
63
+ });
64
+
65
+ test("declares static MIME types needed by the SPA", () => {
66
+ expect(remoteConf).toContain("default_type application/octet-stream;");
67
+ expect(remoteConf).toContain("types {");
68
+ expect(remoteConf).toContain("application/javascript js mjs;");
69
+ expect(remoteConf).toContain("text/css css;");
70
+ expect(remoteConf).toContain("text/html html htm;");
71
+ expect(remoteConf).toContain("font/woff2 woff2;");
72
+ expect(remoteConf).toContain("image/svg+xml svg svgz;");
73
+ });
74
+
75
+ test("serves the remote web SPA from /assistant when configured", () => {
76
+ expect(remoteConf).toContain("location = / {");
77
+ expect(remoteConf).toContain("return 302 /assistant/;");
78
+ expect(remoteConf.indexOf("location = / {")).toBeLessThan(
79
+ remoteConf.indexOf("location / {"),
80
+ );
81
+ expect(remoteConf).toContain("location = /assistant {");
82
+ expect(remoteConf).toContain("return 302 /assistant/;");
83
+ expect(remoteConf).toContain("location ^~ /assistant/assets/ {");
84
+ expect(remoteConf).toContain('alias "/tmp/vellum web/dist/assets/";');
85
+ expect(remoteConf).toContain("try_files $uri =404;");
86
+ expect(remoteConf).toContain("location = /assistant/ {");
87
+ expect(remoteConf).toContain(
88
+ "rewrite ^ /assistant/__remote-index.html last;",
89
+ );
90
+ expect(remoteConf).toContain("location = /assistant/index.html {");
91
+ expect(remoteConf).toContain("location = /assistant/__remote-index.html {");
92
+ expect(remoteConf).toContain("internal;");
93
+ expect(remoteConf).toContain('alias "/tmp/vellum web/dist/index.html";');
94
+ expect(remoteConf).toContain("location ^~ /assistant/ {");
95
+ expect(remoteConf).toContain('alias "/tmp/vellum web/dist/";');
96
+ expect(remoteConf).toContain(
97
+ "try_files $uri $uri/ /assistant/__remote-index.html;",
98
+ );
99
+ expect(remoteConf).toContain("location / {\n return 404;\n }");
100
+ });
101
+
102
+ test("serves remote web config for the SPA", () => {
103
+ expect(remoteConf).toContain("location = /assistant/__config {");
104
+ expect(remoteConf).toContain("default_type application/json;");
105
+ expect(remoteConf).toContain('add_header Cache-Control "no-store";');
106
+ expect(remoteConf).toContain(
107
+ 'return 200 "{\\"mode\\":\\"remote-gateway\\",\\"apiBaseUrl\\":\\"/v1\\",\\"platformDisabled\\":true,\\"disablePlatform\\":true}";',
108
+ );
109
+ });
110
+
111
+ test("proxies health and public API traffic to the gateway in remote web mode", () => {
112
+ expect(remoteConf).toContain("location = /healthz {");
113
+ expect(remoteConf).toContain("location ^~ /v1/ {");
114
+ expect(remoteConf).toContain("proxy_pass http://127.0.0.1:7830;");
115
+ expect(remoteConf).toContain("proxy_request_buffering off;");
116
+ expect(remoteConf).toContain("proxy_buffering off;");
117
+ expect(remoteConf).toContain(
118
+ 'proxy_set_header X-Vellum-Edge-Forwarded "1";',
119
+ );
120
+ });
121
+
122
+ test("blocks local-only bootstrap helpers before generic API proxying", () => {
123
+ const deniedLocations = [
124
+ "location = /auth/token { return 404; }",
125
+ "location = /auth/token/ { return 404; }",
126
+ "location = /v1/pair { return 404; }",
127
+ "location = /v1/pair/ { return 404; }",
128
+ "location = /v1/pair/web-init { return 404; }",
129
+ "location = /v1/pair/web-init/ { return 404; }",
130
+ "location = /v1/devices { return 404; }",
131
+ "location = /v1/devices/ { return 404; }",
132
+ "location = /v1/devices/revoke { return 404; }",
133
+ "location = /v1/devices/revoke/ { return 404; }",
134
+ "location = /v1/guardian/init { return 404; }",
135
+ "location = /v1/guardian/init/ { return 404; }",
136
+ "location = /v1/guardian/reset-bootstrap { return 404; }",
137
+ "location = /v1/guardian/reset-bootstrap/ { return 404; }",
138
+ "location ^~ /assistant/__local/ { return 404; }",
139
+ "location ^~ /assistant/__gateway/ { return 404; }",
140
+ ];
141
+ for (const location of deniedLocations) {
142
+ expect(remoteConf).toContain(location);
143
+ expect(remoteConf.indexOf(location)).toBeLessThan(
144
+ remoteConf.indexOf("location ^~ /v1/ {"),
145
+ );
146
+ }
147
+ });
148
+
149
+ test("supports websockets and SSE streaming", () => {
150
+ expect(conf).toContain("map $http_upgrade $connection_upgrade");
151
+ expect(conf).toContain("proxy_http_version 1.1;");
152
+ expect(conf).toContain("proxy_set_header Upgrade $http_upgrade;");
153
+ expect(conf).toContain("proxy_set_header Connection $connection_upgrade;");
154
+ expect(conf).toContain("proxy_request_buffering off;");
155
+ expect(conf).toContain("proxy_buffering off;");
156
+ expect(conf).toContain("proxy_read_timeout 1h;");
157
+ });
158
+ });
159
+
160
+ describe("buildRemoteWebIndexHtml", () => {
161
+ test("injects the remote gateway config after any bundled local config", () => {
162
+ const html =
163
+ '<html><head><script>window.__VELLUM_CONFIG__={"webUrl":"https://www.vellum.ai"}</script></head><body></body></html>';
164
+ const result = buildRemoteWebIndexHtml(html, {
165
+ mode: "remote-gateway",
166
+ apiBaseUrl: "/v1",
167
+ disablePlatform: true,
168
+ });
169
+
170
+ expect(result).toContain(
171
+ 'window.__VELLUM_CONFIG__={"webUrl":"https://www.vellum.ai"}',
172
+ );
173
+ expect(result).toContain(
174
+ 'window.__VELLUM_CONFIG__={"mode":"remote-gateway","apiBaseUrl":"/v1","disablePlatform":true}',
175
+ );
176
+ expect(result.indexOf('"webUrl"')).toBeLessThan(
177
+ result.indexOf('"remote-gateway"'),
178
+ );
179
+ });
180
+
181
+ test("escapes config JSON before embedding it in a script tag", () => {
182
+ const result = buildRemoteWebIndexHtml("</head>", {
183
+ value: "</script><script>alert(1)</script>",
184
+ });
185
+
186
+ expect(result).not.toContain("</script><script>alert(1)</script>");
187
+ expect(result).toContain("\\u003c/script\\u003e");
188
+ });
189
+ });
190
+
191
+ describe("nginx ingress process state", () => {
192
+ const workspaces: string[] = [];
193
+
194
+ afterEach(() => {
195
+ process.kill = originalKill;
196
+ execFileSyncMock.mockReset();
197
+ for (const dir of workspaces.splice(0)) {
198
+ rmSync(dir, { recursive: true, force: true });
199
+ }
200
+ });
201
+
202
+ function makeWorkspace(): string {
203
+ const dir = mkdtempSync(join(tmpdir(), "vellum-ingress-test-"));
204
+ workspaces.push(dir);
205
+ return dir;
206
+ }
207
+
208
+ function writeIngressState(workspaceDir: string, listenPort: number): void {
209
+ writeFileSync(
210
+ join(workspaceDir, "config.json"),
211
+ JSON.stringify({ ingress: { nginx: { listenPort } } }) + "\n",
212
+ );
213
+ }
214
+
215
+ function writePidFile(workspaceDir: string, pid: number): void {
216
+ const dir = join(workspaceDir, "data", "ingress");
217
+ mkdirSync(dir, { recursive: true });
218
+ writeFileSync(join(dir, "nginx.pid"), `${pid}\n`);
219
+ }
220
+
221
+ function readConfig(workspaceDir: string): Record<string, unknown> {
222
+ return JSON.parse(
223
+ readFileSync(join(workspaceDir, "config.json"), "utf-8"),
224
+ ) as Record<string, unknown>;
225
+ }
226
+
227
+ function pidPath(workspaceDir: string): string {
228
+ return join(workspaceDir, "data", "ingress", "nginx.pid");
229
+ }
230
+
231
+ function nginxCommand(workspaceDir: string): string {
232
+ const dir = join(workspaceDir, "data", "ingress");
233
+ return `nginx: master process nginx -p ${dir} -c ${join(dir, "nginx.conf")} -g daemon off;`;
234
+ }
235
+
236
+ /** A PID guaranteed dead: a short-lived child that has already exited. */
237
+ function deadPid(): number {
238
+ const result = childProcess.spawnSync("sh", ["-c", "exit 0"]);
239
+ if (!result.pid) throw new Error("failed to spawn probe process");
240
+ return result.pid;
241
+ }
242
+
243
+ test("falls back to the gateway port when no ingress state exists", () => {
244
+ const ws = makeWorkspace();
245
+ expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
246
+ port: 7830,
247
+ viaIngress: false,
248
+ });
249
+ });
250
+
251
+ test("falls back when ingress state exists but the process is dead", () => {
252
+ const ws = makeWorkspace();
253
+ writeIngressState(ws, 7841);
254
+ writePidFile(ws, deadPid());
255
+ expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
256
+ port: 7830,
257
+ viaIngress: false,
258
+ });
259
+ });
260
+
261
+ test("falls back when the recorded PID belongs to a non-nginx process", () => {
262
+ const ws = makeWorkspace();
263
+ writeIngressState(ws, 7841);
264
+ writePidFile(ws, process.pid);
265
+ execFileSyncMock.mockReturnValue("bun test");
266
+ expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
267
+ port: 7830,
268
+ viaIngress: false,
269
+ });
270
+ });
271
+
272
+ test("falls back when the recorded PID belongs to another nginx instance", () => {
273
+ const ws = makeWorkspace();
274
+ writeIngressState(ws, 7841);
275
+ writePidFile(ws, process.pid);
276
+ execFileSyncMock.mockReturnValue(
277
+ "nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
278
+ );
279
+ expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
280
+ port: 7830,
281
+ viaIngress: false,
282
+ });
283
+ });
284
+
285
+ test("targets the ingress when state exists and the PID is this nginx", () => {
286
+ const ws = makeWorkspace();
287
+ writeIngressState(ws, 7841);
288
+ writePidFile(ws, process.pid);
289
+ execFileSyncMock.mockReturnValue(nginxCommand(ws));
290
+ expect(resolveTunnelTargetPort(ws, 7830)).toEqual({
291
+ port: 7841,
292
+ viaIngress: true,
293
+ });
294
+ });
295
+
296
+ test("falls back when nginx ingress is not preferred", () => {
297
+ const ws = makeWorkspace();
298
+ writeIngressState(ws, 7841);
299
+ writePidFile(ws, process.pid);
300
+ execFileSyncMock.mockReturnValue(nginxCommand(ws));
301
+ expect(
302
+ resolveTunnelTargetPort(ws, 7830, { preferNginxIngress: false }),
303
+ ).toEqual({
304
+ port: 7830,
305
+ viaIngress: false,
306
+ });
307
+ });
308
+
309
+ test("clears ingress state after nginx is confirmed stopped", async () => {
310
+ const ws = makeWorkspace();
311
+ const pid = 123_456;
312
+ let alive = true;
313
+ writeIngressState(ws, 7841);
314
+ writePidFile(ws, pid);
315
+ execFileSyncMock.mockReturnValue(nginxCommand(ws));
316
+ process.kill = mock((targetPid: number, signal?: string | number) => {
317
+ if (targetPid !== pid) return originalKill(targetPid, signal);
318
+ if (signal === 0) {
319
+ if (!alive) throw new Error("dead");
320
+ return true;
321
+ }
322
+ if (signal === "SIGTERM") {
323
+ alive = false;
324
+ return true;
325
+ }
326
+ return true;
327
+ }) as unknown as typeof process.kill;
328
+
329
+ await expect(stopIngressNginx(ws)).resolves.toBe(true);
330
+
331
+ const config = readConfig(ws);
332
+ expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
333
+ expect(existsSync(pidPath(ws))).toBe(false);
334
+ });
335
+
336
+ test("keeps ingress state when nginx kill fails", async () => {
337
+ const ws = makeWorkspace();
338
+ const pid = 123_457;
339
+ writeIngressState(ws, 7841);
340
+ writePidFile(ws, pid);
341
+ execFileSyncMock.mockReturnValue(nginxCommand(ws));
342
+ process.kill = mock((targetPid: number, signal?: string | number) => {
343
+ if (targetPid !== pid) return originalKill(targetPid, signal);
344
+ if (signal === 0) return true;
345
+ throw new Error("operation not permitted");
346
+ }) as unknown as typeof process.kill;
347
+
348
+ await expect(stopIngressNginx(ws)).resolves.toBe(false);
349
+
350
+ const config = readConfig(ws);
351
+ expect((config.ingress as Record<string, unknown>).nginx).toEqual({
352
+ listenPort: 7841,
353
+ });
354
+ expect(existsSync(pidPath(ws))).toBe(true);
355
+ });
356
+
357
+ test("clears ingress state when nginx exits before SIGTERM", async () => {
358
+ const ws = makeWorkspace();
359
+ const pid = 123_458;
360
+ let aliveChecks = 0;
361
+ writeIngressState(ws, 7841);
362
+ writePidFile(ws, pid);
363
+ execFileSyncMock.mockReturnValue(nginxCommand(ws));
364
+ process.kill = mock((targetPid: number, signal?: string | number) => {
365
+ if (targetPid !== pid) return originalKill(targetPid, signal);
366
+ if (signal === 0) {
367
+ aliveChecks++;
368
+ if (aliveChecks === 1) return true;
369
+ throw new Error("dead");
370
+ }
371
+ throw new Error("no such process");
372
+ }) as unknown as typeof process.kill;
373
+
374
+ await expect(stopIngressNginx(ws)).resolves.toBe(true);
375
+
376
+ const config = readConfig(ws);
377
+ expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
378
+ expect(existsSync(pidPath(ws))).toBe(false);
379
+ });
380
+
381
+ test("does not kill another nginx instance when clearing stale state", async () => {
382
+ const ws = makeWorkspace();
383
+ const pid = 123_459;
384
+ writeIngressState(ws, 7841);
385
+ writePidFile(ws, pid);
386
+ execFileSyncMock.mockReturnValue(
387
+ "nginx: master process nginx -p /tmp/other-ingress -c /tmp/other-ingress/nginx.conf",
388
+ );
389
+ process.kill = mock((targetPid: number, signal?: string | number) => {
390
+ if (targetPid !== pid) return originalKill(targetPid, signal);
391
+ if (signal === 0) return true;
392
+ throw new Error("should not kill another nginx instance");
393
+ }) as unknown as typeof process.kill;
394
+
395
+ await expect(stopIngressNginx(ws)).resolves.toBe(false);
396
+
397
+ const config = readConfig(ws);
398
+ expect((config.ingress as Record<string, unknown>).nginx).toBeUndefined();
399
+ expect(existsSync(pidPath(ws))).toBe(false);
400
+ });
401
+ });
@@ -155,10 +155,14 @@ describe("sleep command", () => {
155
155
  }
156
156
 
157
157
  expect(stopProcessByPidFileMock).toHaveBeenCalledTimes(2);
158
+ // The assistant stop passes a generous 120s grace so the daemon's WAL
159
+ // checkpoint completes before any SIGKILL (default 2s would truncate it).
158
160
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
159
161
  1,
160
162
  join(assistantRootDir, "workspace", "vellum.pid"),
161
163
  "assistant",
164
+ undefined,
165
+ 120_000,
162
166
  );
163
167
  expect(stopProcessByPidFileMock).toHaveBeenNthCalledWith(
164
168
  2,
@@ -773,15 +773,12 @@ describe("resolveOrHatchTarget", () => {
773
773
  });
774
774
 
775
775
  const result = await resolveOrHatchTarget("docker", "new-one");
776
- expect(hatchDockerMock).toHaveBeenCalledWith(
777
- "vellum",
778
- false,
779
- "new-one",
780
- false,
781
- {},
782
- {},
783
- { setupProviderCredentials: false },
784
- );
776
+ expect(hatchDockerMock).toHaveBeenCalledWith({
777
+ species: "vellum",
778
+ detached: false,
779
+ name: "new-one",
780
+ setupProviderCredentials: false,
781
+ });
785
782
  expect(result).toBe(newEntry);
786
783
  });
787
784
 
@@ -0,0 +1,164 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ import * as cloudflareTunnel from "../lib/cloudflare-tunnel.js";
15
+ import * as ngrok from "../lib/ngrok.js";
16
+ import type { AssistantEntry } from "../lib/assistant-config.js";
17
+
18
+ const realCloudflareTunnel = { ...cloudflareTunnel };
19
+ const realNgrok = { ...ngrok };
20
+
21
+ const runCloudflareTunnelMock = mock<
22
+ typeof cloudflareTunnel.runCloudflareTunnel
23
+ >(async () => {});
24
+ mock.module("../lib/cloudflare-tunnel.js", () => ({
25
+ ...realCloudflareTunnel,
26
+ runCloudflareTunnel: runCloudflareTunnelMock,
27
+ }));
28
+
29
+ const runNgrokTunnelMock = mock<typeof ngrok.runNgrokTunnel>(async () => {});
30
+ mock.module("../lib/ngrok", () => ({
31
+ ...realNgrok,
32
+ runNgrokTunnel: runNgrokTunnelMock,
33
+ }));
34
+
35
+ const { tunnel } = await import("../commands/tunnel.js");
36
+
37
+ const originalArgv = [...process.argv];
38
+ const originalFetch = globalThis.fetch;
39
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
40
+ const tempDirs: string[] = [];
41
+
42
+ function makeLocalEntry(): AssistantEntry {
43
+ const instanceDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-test-"));
44
+ tempDirs.push(instanceDir);
45
+ return {
46
+ assistantId: "assistant-1",
47
+ runtimeUrl: "http://127.0.0.1:7830",
48
+ cloud: "local",
49
+ resources: {
50
+ instanceDir,
51
+ daemonPort: 7821,
52
+ gatewayPort: 7830,
53
+ qdrantPort: 6333,
54
+ cesPort: 7822,
55
+ },
56
+ };
57
+ }
58
+
59
+ function writeLockfile(entry: AssistantEntry): void {
60
+ const lockfileDir = mkdtempSync(join(tmpdir(), "vellum-tunnel-lockfile-"));
61
+ tempDirs.push(lockfileDir);
62
+ process.env.VELLUM_LOCKFILE_DIR = lockfileDir;
63
+ mkdirSync(lockfileDir, { recursive: true });
64
+ writeFileSync(
65
+ join(lockfileDir, ".vellum.lock.json"),
66
+ JSON.stringify(
67
+ {
68
+ activeAssistant: entry.assistantId,
69
+ assistants: [entry],
70
+ },
71
+ null,
72
+ 2,
73
+ ),
74
+ );
75
+ }
76
+
77
+ function mockEnabledFlagFetch() {
78
+ const fetchMock = mock(async (_input: string, _init?: RequestInit) => {
79
+ return new Response(
80
+ JSON.stringify({
81
+ flags: [{ key: "web-remote-ingress", enabled: true }],
82
+ }),
83
+ { status: 200, headers: { "Content-Type": "application/json" } },
84
+ );
85
+ });
86
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
87
+ return fetchMock;
88
+ }
89
+
90
+ describe("tunnel nginx ingress feature flag", () => {
91
+ beforeEach(() => {
92
+ process.argv = ["bun", "vellum", "tunnel"];
93
+ writeLockfile(makeLocalEntry());
94
+ globalThis.fetch = (async () => {
95
+ throw new Error("gateway unavailable");
96
+ }) as unknown as typeof globalThis.fetch;
97
+ runCloudflareTunnelMock.mockReset();
98
+ runCloudflareTunnelMock.mockResolvedValue(undefined);
99
+ runNgrokTunnelMock.mockReset();
100
+ runNgrokTunnelMock.mockResolvedValue(undefined);
101
+ });
102
+
103
+ afterEach(() => {
104
+ process.argv = originalArgv;
105
+ globalThis.fetch = originalFetch;
106
+ if (originalLockfileDir === undefined) {
107
+ delete process.env.VELLUM_LOCKFILE_DIR;
108
+ } else {
109
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
110
+ }
111
+ for (const dir of tempDirs.splice(0)) {
112
+ rmSync(dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ afterAll(() => {
117
+ mock.module("../lib/cloudflare-tunnel.js", () => realCloudflareTunnel);
118
+ mock.module("../lib/ngrok", () => realNgrok);
119
+ });
120
+
121
+ test("does not start ngrok when the flag lookup fails", async () => {
122
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
123
+
124
+ await expect(tunnel()).rejects.toThrow(
125
+ "Could not verify the `web-remote-ingress` feature flag",
126
+ );
127
+
128
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
129
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ test("checks the nginx flag through the local gateway for ngrok", async () => {
133
+ const entry = makeLocalEntry();
134
+ entry.runtimeUrl = "https://stale-tunnel.ngrok-free.dev";
135
+ writeLockfile(entry);
136
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "ngrok"];
137
+ const fetchMock = mockEnabledFlagFetch();
138
+
139
+ await tunnel();
140
+
141
+ const [url, init] = fetchMock.mock.calls[0];
142
+ expect(url).toBe(
143
+ "http://127.0.0.1:7830/v1/assistants/assistant-1/feature-flags",
144
+ );
145
+ expect(init?.method).toBe("GET");
146
+ expect(runNgrokTunnelMock).toHaveBeenCalledWith({
147
+ port: 7830,
148
+ workspaceDir: join(entry.resources!.instanceDir, ".vellum", "workspace"),
149
+ preferNginxIngress: true,
150
+ });
151
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
152
+ });
153
+
154
+ test("does not start cloudflared when the flag lookup fails", async () => {
155
+ process.argv = ["bun", "vellum", "tunnel", "--provider", "cloudflare"];
156
+
157
+ await expect(tunnel()).rejects.toThrow(
158
+ "Could not verify the `web-remote-ingress` feature flag",
159
+ );
160
+
161
+ expect(runNgrokTunnelMock).not.toHaveBeenCalled();
162
+ expect(runCloudflareTunnelMock).not.toHaveBeenCalled();
163
+ });
164
+ });