@vellumai/cli 0.8.12 → 0.9.0-dev.202606162243.4268db3

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 (54) hide show
  1. package/README.md +1 -1
  2. package/bun.lock +49 -56
  3. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  5. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  7. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  8. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  9. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  10. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  11. package/package.json +3 -3
  12. package/src/__tests__/assistant-config.test.ts +1 -2
  13. package/src/__tests__/device-id.test.ts +6 -14
  14. package/src/__tests__/helpers/os-mock.ts +27 -0
  15. package/src/__tests__/login-loopback.test.ts +71 -0
  16. package/src/__tests__/multi-local.test.ts +2 -10
  17. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  18. package/src/__tests__/nginx-ingress.test.ts +403 -0
  19. package/src/__tests__/sleep.test.ts +4 -0
  20. package/src/__tests__/teleport.test.ts +6 -9
  21. package/src/__tests__/tunnel.test.ts +164 -0
  22. package/src/__tests__/wake.test.ts +15 -4
  23. package/src/__tests__/workos-pkce.test.ts +314 -0
  24. package/src/commands/flags.ts +1 -22
  25. package/src/commands/hatch.ts +90 -9
  26. package/src/commands/login.ts +123 -59
  27. package/src/commands/nginx-ingress.ts +291 -0
  28. package/src/commands/rollback.ts +0 -6
  29. package/src/commands/sleep.ts +17 -0
  30. package/src/commands/teleport.ts +23 -36
  31. package/src/commands/tunnel.ts +69 -11
  32. package/src/commands/upgrade.ts +0 -2
  33. package/src/commands/wake.ts +7 -5
  34. package/src/commands/workflows.ts +301 -0
  35. package/src/index.ts +8 -0
  36. package/src/lib/arg-utils.ts +48 -0
  37. package/src/lib/assistant-client.ts +2 -0
  38. package/src/lib/assistant-config.ts +0 -7
  39. package/src/lib/cloudflare-tunnel.ts +15 -2
  40. package/src/lib/docker.ts +103 -49
  41. package/src/lib/environments/resolve.ts +3 -4
  42. package/src/lib/feature-flags.test.ts +157 -0
  43. package/src/lib/feature-flags.ts +38 -0
  44. package/src/lib/hatch-local.ts +0 -1
  45. package/src/lib/local.ts +5 -0
  46. package/src/lib/nginx-ingress.ts +576 -0
  47. package/src/lib/ngrok.ts +26 -4
  48. package/src/lib/platform-client.ts +0 -1
  49. package/src/lib/retire-local.ts +5 -0
  50. package/src/lib/statefulset.ts +73 -21
  51. package/src/lib/sync-cloud-assistants.ts +4 -17
  52. package/src/lib/upgrade-lifecycle.ts +1 -2
  53. package/src/lib/workos-pkce.ts +160 -0
  54. package/src/__tests__/env-drift.test.ts +0 -53
package/src/lib/docker.ts CHANGED
@@ -15,7 +15,6 @@ import cliPkg from "../../package.json";
15
15
 
16
16
  import {
17
17
  findAssistantByName,
18
- normalizeVersion,
19
18
  saveAssistantEntry,
20
19
  setActiveAssistant,
21
20
  } from "./assistant-config";
@@ -275,8 +274,53 @@ function ensureLocalBinOnPath(): void {
275
274
  }
276
275
  }
277
276
 
278
- export interface HatchDockerOptions {
277
+ export interface HatchDockerParams {
278
+ /** Assistant species to hatch (e.g. `"vellum"`). */
279
+ species: Species;
280
+ /** Run detached without attaching to logs or interactive setup. */
281
+ detached?: boolean;
282
+ /** Instance display name. Defaults to an auto-generated name. */
283
+ name?: string | null;
284
+ /** Build from a local source tree and hot-reload on change. */
285
+ watch?: boolean;
286
+ /** Hatch-time config values (key → value). */
287
+ configValues?: Record<string, string>;
288
+ /** Extra env vars forwarded into the assistant container. */
289
+ flagEnvVars?: Record<string, string>;
279
290
  setupProviderCredentials?: boolean;
291
+ /**
292
+ * Path to a local source tree to build images from before hatching. When
293
+ * provided, this path is used directly as the repo root and no file
294
+ * watcher is started — useful for callers (e.g. evals) that want each
295
+ * run to pick up local CLI changes without keeping a long-lived watcher
296
+ * process around. `--watch` independently auto-detects the repo root and
297
+ * also enables hot-reload.
298
+ */
299
+ sourcePath?: string | null;
300
+ analyze?: boolean;
301
+ /**
302
+ * Name of an existing container whose network namespace the assistant,
303
+ * gateway, and credential-executor join (`--network=container:<name>`),
304
+ * instead of the assistant owning a freshly-created per-instance network.
305
+ * When set, hatch creates no Docker network and publishes no host ports —
306
+ * the namespace owner is responsible for publishing the gateway port — so
307
+ * `gatewayPort` must also be supplied (the owner had to publish it before
308
+ * hatch ran).
309
+ */
310
+ netnsContainer?: string;
311
+ /**
312
+ * Explicit host port to record as the gateway's `runtimeUrl` instead of
313
+ * auto-allocating a free port. Required alongside `netnsContainer`, where
314
+ * the namespace owner — not hatch — owns port allocation and publishing.
315
+ */
316
+ gatewayPort?: number;
317
+ /**
318
+ * Host path to a PEM CA bundle bind-mounted into the assistant container
319
+ * and trusted at process start via `NODE_EXTRA_CA_CERTS`. Lets the daemon
320
+ * trust a TLS-terminating egress proxy from its very first outbound
321
+ * connection.
322
+ */
323
+ assistantCaCertPath?: string;
280
324
  }
281
325
 
282
326
  export type DockerProviderCredentialSetupAction =
@@ -670,6 +714,8 @@ export async function startContainers(
670
714
  imageTags: Record<ServiceName, string>;
671
715
  instanceName: string;
672
716
  res: ReturnType<typeof dockerResourceNames>;
717
+ netnsContainer?: string;
718
+ assistantCaCertPath?: string;
673
719
  },
674
720
  log: (msg: string) => void,
675
721
  ): Promise<void> {
@@ -892,6 +938,8 @@ function startFileWatcher(opts: {
892
938
  instanceName: string;
893
939
  repoRoot: string;
894
940
  res: ReturnType<typeof dockerResourceNames>;
941
+ netnsContainer?: string;
942
+ assistantCaCertPath?: string;
895
943
  }): () => void {
896
944
  const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
897
945
 
@@ -913,6 +961,8 @@ function startFileWatcher(opts: {
913
961
  instanceName,
914
962
  res,
915
963
  avatarDevicePath: resolveAvatarDevicePath(),
964
+ netnsContainer: opts.netnsContainer,
965
+ assistantCaCertPath: opts.assistantCaCertPath,
916
966
  });
917
967
  const containerForService: Record<ServiceName, string> = {
918
968
  assistant: res.assistantContainer,
@@ -1031,31 +1081,19 @@ function startFileWatcher(opts: {
1031
1081
  };
1032
1082
  }
1033
1083
 
1034
- export interface HatchDockerOptions {
1035
- /**
1036
- * Path to a local source tree to build images from before hatching. When
1037
- * provided, this path is used directly as the repo root and no file
1038
- * watcher is started — useful for callers (e.g. evals) that want each
1039
- * run to pick up local CLI changes without keeping a long-lived watcher
1040
- * process around. `--watch` independently auto-detects the repo root and
1041
- * also enables hot-reload.
1042
- */
1043
- sourcePath?: string | null;
1044
- analyze?: boolean;
1045
- }
1084
+ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
1085
+ const {
1086
+ species,
1087
+ detached = false,
1088
+ name = null,
1089
+ configValues = {},
1090
+ flagEnvVars = {},
1091
+ } = params;
1092
+ let watch = params.watch ?? false;
1046
1093
 
1047
- export async function hatchDocker(
1048
- species: Species,
1049
- detached: boolean,
1050
- name: string | null,
1051
- watch: boolean = false,
1052
- configValues: Record<string, string> = {},
1053
- flagEnvVars: Record<string, string> = {},
1054
- options: HatchDockerOptions = {},
1055
- ): Promise<void> {
1056
1094
  resetLogFile("hatch.log");
1057
1095
  const provider =
1058
- options.setupProviderCredentials === false
1096
+ params.setupProviderCredentials === false
1059
1097
  ? undefined
1060
1098
  : resolveHatchProvider(configValues);
1061
1099
 
@@ -1070,21 +1108,32 @@ export async function hatchDocker(
1070
1108
  await ensureDockerInstalled();
1071
1109
 
1072
1110
  const instanceName = generateInstanceName(species, name);
1073
- // Resolve the gateway's host port dynamically. The env-default
1111
+ // Resolve the gateway's host port. When joining an externally-owned
1112
+ // network namespace, the owner has already published the gateway port,
1113
+ // so the caller — not hatch — owns port allocation; use the supplied
1114
+ // port verbatim. Otherwise resolve it dynamically: the env-default
1074
1115
  // (production 7830 / non-prod overrides) is just the *preferred*
1075
- // starting point — if it's taken by another local assistant, eval
1076
- // run, or unrelated process, we walk upward until we find a free
1077
- // port. This replaces the previous "first one in wins, everyone
1078
- // else gets a docker bind error" behavior and removes the need for
1079
- // an orphan-cleanup pre-flight in the evals harness.
1080
- const preferredGatewayPort = getDefaultPorts(
1081
- getCurrentEnvironment(),
1082
- ).gateway;
1083
- const gatewayPort = await findOpenPort(preferredGatewayPort);
1084
- if (gatewayPort !== preferredGatewayPort) {
1085
- log(
1086
- `Preferred gateway port ${preferredGatewayPort} is in use; allocated ${gatewayPort} for this instance.`,
1087
- );
1116
+ // starting point — if it's taken by another local assistant, eval run,
1117
+ // or unrelated process, we walk upward until we find a free port, so
1118
+ // concurrent instances don't collide on a docker bind error.
1119
+ let gatewayPort: number;
1120
+ if (params.netnsContainer) {
1121
+ if (params.gatewayPort === undefined) {
1122
+ throw new Error(
1123
+ "hatchDocker: gatewayPort is required when netnsContainer is set (the namespace owner publishes the port before hatch runs)",
1124
+ );
1125
+ }
1126
+ gatewayPort = params.gatewayPort;
1127
+ } else {
1128
+ const preferredGatewayPort = getDefaultPorts(
1129
+ getCurrentEnvironment(),
1130
+ ).gateway;
1131
+ gatewayPort = await findOpenPort(preferredGatewayPort);
1132
+ if (gatewayPort !== preferredGatewayPort) {
1133
+ log(
1134
+ `Preferred gateway port ${preferredGatewayPort} is in use; allocated ${gatewayPort} for this instance.`,
1135
+ );
1136
+ }
1088
1137
  }
1089
1138
 
1090
1139
  const imageTags: Record<ServiceName, string> = {
@@ -1094,8 +1143,8 @@ export async function hatchDocker(
1094
1143
  };
1095
1144
 
1096
1145
  const sourcePath =
1097
- typeof options.sourcePath === "string" && options.sourcePath.length > 0
1098
- ? options.sourcePath
1146
+ typeof params.sourcePath === "string" && params.sourcePath.length > 0
1147
+ ? params.sourcePath
1099
1148
  : null;
1100
1149
  const buildFromSource = sourcePath !== null;
1101
1150
  let repoRoot: string | undefined;
@@ -1152,8 +1201,6 @@ export async function hatchDocker(
1152
1201
  log("✅ Docker images built");
1153
1202
  }
1154
1203
 
1155
- let releaseVersion: string | undefined;
1156
-
1157
1204
  if (!mode.build || !repoRoot) {
1158
1205
  emitProgress(2, 6, "Pulling images...");
1159
1206
 
@@ -1190,9 +1237,6 @@ export async function hatchDocker(
1190
1237
  `⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
1191
1238
  );
1192
1239
  }
1193
- if (versionTag !== "latest") {
1194
- releaseVersion = normalizeVersion(versionTag);
1195
- }
1196
1240
  log("🔍 Resolving image references...");
1197
1241
  const resolved = await resolveImageRefs(versionTag, log);
1198
1242
  imageTags.assistant = resolved.imageTags.assistant;
@@ -1247,8 +1291,15 @@ export async function hatchDocker(
1247
1291
  const res = dockerResourceNames(instanceName);
1248
1292
 
1249
1293
  emitProgress(3, 6, "Creating volumes...");
1250
- log("📁 Creating network and volumes...");
1251
- await exec("docker", ["network", "create", res.network]);
1294
+ // When joining an externally-owned network namespace, the owner already
1295
+ // provides the network stack — creating a per-instance network here would
1296
+ // be unused and leak on teardown.
1297
+ if (params.netnsContainer) {
1298
+ log("📁 Joining existing network namespace; creating volumes...");
1299
+ } else {
1300
+ log("📁 Creating network and volumes...");
1301
+ await exec("docker", ["network", "create", res.network]);
1302
+ }
1252
1303
  await exec("docker", ["volume", "create", res.socketVolume]);
1253
1304
  await exec("docker", ["volume", "create", res.assistantIpcVolume]);
1254
1305
  await exec("docker", ["volume", "create", res.gatewayIpcVolume]);
@@ -1356,6 +1407,8 @@ export async function hatchDocker(
1356
1407
  imageTags,
1357
1408
  instanceName,
1358
1409
  res,
1410
+ netnsContainer: params.netnsContainer,
1411
+ assistantCaCertPath: params.assistantCaCertPath,
1359
1412
  },
1360
1413
  log,
1361
1414
  );
@@ -1370,7 +1423,6 @@ export async function hatchDocker(
1370
1423
  cloud: "docker",
1371
1424
  species,
1372
1425
  hatchedAt: new Date().toISOString(),
1373
- version: releaseVersion,
1374
1426
  guardianBootstrapSecret: ownSecret,
1375
1427
  containerInfo: {
1376
1428
  assistantImage: imageTags.assistant,
@@ -1396,7 +1448,7 @@ export async function hatchDocker(
1396
1448
  logFd,
1397
1449
  runtimeUrl,
1398
1450
  containersUpAt,
1399
- analyze: options.analyze ?? false,
1451
+ analyze: params.analyze ?? false,
1400
1452
  });
1401
1453
 
1402
1454
  if (!ready && !(watch && repoRoot)) {
@@ -1454,6 +1506,8 @@ export async function hatchDocker(
1454
1506
  instanceName,
1455
1507
  repoRoot,
1456
1508
  res,
1509
+ netnsContainer: params.netnsContainer,
1510
+ assistantCaCertPath: params.assistantCaCertPath,
1457
1511
  });
1458
1512
 
1459
1513
  await new Promise<void>((resolve) => {
@@ -87,10 +87,9 @@ export function getCurrentEnvironment(
87
87
  if (!seed) {
88
88
  if (name !== DEFAULT_ENVIRONMENT_NAME) {
89
89
  // Warn on stderr instead of throwing, to match the silent-fallback
90
- // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName
91
- // and clients/shared/App/VellumEnvironment.swift:current. Those two
92
- // silently fall back to production; the CLI should agree so all three
93
- // writers don't end up in disjoint states on a typo.
90
+ // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName,
91
+ // which silently falls back to production; the CLI agrees so neither
92
+ // writer ends up in a disjoint state on a typo.
94
93
  process.stderr.write(
95
94
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
96
95
  `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
@@ -0,0 +1,157 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ test,
8
+ } from "bun:test";
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import {
14
+ isAssistantFeatureFlagEnabled,
15
+ WEB_REMOTE_INGRESS_FLAG,
16
+ } from "./feature-flags.js";
17
+
18
+ const testDir = mkdtempSync(join(tmpdir(), "cli-feature-flags-test-"));
19
+ const originalFetch = globalThis.fetch;
20
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
21
+
22
+ function writeLockfile(): void {
23
+ mkdirSync(testDir, { recursive: true });
24
+ writeFileSync(
25
+ join(testDir, ".vellum.lock.json"),
26
+ JSON.stringify(
27
+ {
28
+ activeAssistant: "assistant-1",
29
+ assistants: [
30
+ {
31
+ assistantId: "assistant-1",
32
+ runtimeUrl: "http://127.0.0.1:7830",
33
+ cloud: "local",
34
+ },
35
+ ],
36
+ },
37
+ null,
38
+ 2,
39
+ ),
40
+ );
41
+ }
42
+
43
+ function jsonResponse(body: unknown, status = 200): Response {
44
+ return new Response(JSON.stringify(body), {
45
+ status,
46
+ headers: { "content-type": "application/json" },
47
+ });
48
+ }
49
+
50
+ function mockFetch(response: Response): void {
51
+ const fetchMock = async () => response;
52
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
53
+ }
54
+
55
+ function mockFetchWithUrls(response: Response): string[] {
56
+ const urls: string[] = [];
57
+ const fetchMock = async (input: RequestInfo | URL) => {
58
+ urls.push(String(input));
59
+ return response;
60
+ };
61
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
62
+ return urls;
63
+ }
64
+
65
+ describe("isAssistantFeatureFlagEnabled", () => {
66
+ beforeEach(() => {
67
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
68
+ rmSync(join(testDir, ".vellum.lock.json"), { force: true });
69
+ writeLockfile();
70
+ });
71
+
72
+ afterEach(() => {
73
+ globalThis.fetch = originalFetch;
74
+ });
75
+
76
+ afterAll(() => {
77
+ if (originalLockfileDir === undefined) {
78
+ delete process.env.VELLUM_LOCKFILE_DIR;
79
+ } else {
80
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
81
+ }
82
+ rmSync(testDir, { recursive: true, force: true });
83
+ });
84
+
85
+ test("returns true when the assistant flag is enabled", async () => {
86
+ mockFetch(
87
+ jsonResponse({
88
+ flags: [{ key: WEB_REMOTE_INGRESS_FLAG, enabled: true }],
89
+ }),
90
+ );
91
+
92
+ await expect(
93
+ isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
94
+ ).resolves.toBe(true);
95
+ });
96
+
97
+ test("uses the supplied runtime URL instead of a stale lockfile runtimeUrl", async () => {
98
+ writeFileSync(
99
+ join(testDir, ".vellum.lock.json"),
100
+ JSON.stringify(
101
+ {
102
+ activeAssistant: "assistant-1",
103
+ assistants: [
104
+ {
105
+ assistantId: "assistant-1",
106
+ runtimeUrl: "https://stale-tunnel.ngrok-free.dev",
107
+ cloud: "local",
108
+ },
109
+ ],
110
+ },
111
+ null,
112
+ 2,
113
+ ),
114
+ );
115
+ const urls = mockFetchWithUrls(
116
+ jsonResponse({
117
+ flags: [{ key: WEB_REMOTE_INGRESS_FLAG, enabled: true }],
118
+ }),
119
+ );
120
+
121
+ await expect(
122
+ isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG, {
123
+ runtimeUrl: "http://127.0.0.1:9123",
124
+ }),
125
+ ).resolves.toBe(true);
126
+
127
+ expect(urls).toEqual([
128
+ "http://127.0.0.1:9123/v1/assistants/assistant-1/feature-flags",
129
+ ]);
130
+ });
131
+
132
+ test("returns false when the assistant flag is disabled or missing", async () => {
133
+ mockFetch(
134
+ jsonResponse({
135
+ flags: [{ key: WEB_REMOTE_INGRESS_FLAG, enabled: false }],
136
+ }),
137
+ );
138
+
139
+ await expect(
140
+ isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
141
+ ).resolves.toBe(false);
142
+
143
+ mockFetch(jsonResponse({ flags: [] }));
144
+
145
+ await expect(
146
+ isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
147
+ ).resolves.toBe(false);
148
+ });
149
+
150
+ test("throws when the gateway rejects the flag request", async () => {
151
+ mockFetch(jsonResponse({ error: "nope" }, 500));
152
+
153
+ await expect(
154
+ isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
155
+ ).rejects.toThrow("Failed to fetch feature flags");
156
+ });
157
+ });
@@ -0,0 +1,38 @@
1
+ import { AssistantClient } from "./assistant-client.js";
2
+
3
+ export const WEB_REMOTE_INGRESS_FLAG = "web-remote-ingress";
4
+
5
+ type FeatureFlagEntry = {
6
+ key?: unknown;
7
+ enabled?: unknown;
8
+ };
9
+
10
+ type FeatureFlagsResponse = {
11
+ flags?: FeatureFlagEntry[];
12
+ };
13
+
14
+ export async function isAssistantFeatureFlagEnabled(
15
+ assistantId: string,
16
+ key: string,
17
+ opts: { runtimeUrl?: string } = {},
18
+ ): Promise<boolean> {
19
+ const client = new AssistantClient({
20
+ assistantId,
21
+ runtimeUrl: opts.runtimeUrl,
22
+ });
23
+ const res = await client.get("/feature-flags");
24
+ if (!res.ok) {
25
+ const body = await res.text().catch(() => "");
26
+ throw new Error(
27
+ `Failed to fetch feature flags: HTTP ${res.status} ${body}`.trim(),
28
+ );
29
+ }
30
+
31
+ const data = (await res.json()) as FeatureFlagsResponse;
32
+ const flag = data.flags?.find((entry) => entry.key === key);
33
+ return flag?.enabled === true;
34
+ }
35
+
36
+ export function formatFeatureFlagGateMessage(flagKey: string): string {
37
+ return `This command is behind the \`${flagKey}\` feature flag. Enable it with \`vellum flags set ${flagKey} true\` and try again.`;
38
+ }
@@ -290,7 +290,6 @@ export async function hatchLocal(
290
290
  cloud: "local",
291
291
  species,
292
292
  hatchedAt: new Date().toISOString(),
293
- version: cliPkg.version,
294
293
  resources: { ...resources, signingKey },
295
294
  guardianBootstrapSecret: bootstrapSecret,
296
295
  };
package/src/lib/local.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "./assistant-config.js";
18
18
  import { GATEWAY_PORT } from "./constants.js";
19
19
  import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
20
+ import { stopIngressNginx } from "./nginx-ingress.js";
20
21
  import {
21
22
  resolveProcessState,
22
23
  stopProcess,
@@ -1240,4 +1241,8 @@ export async function stopLocalProcesses(
1240
1241
  unlinkSync(ngrokPidFile);
1241
1242
  } catch {}
1242
1243
  }
1244
+
1245
+ // Stop the nginx ingress if one is fronting this gateway (it guards against
1246
+ // PID reuse itself, mirroring the ngrok handling above).
1247
+ await stopIngressNginx(join(vellumDir, "workspace"));
1243
1248
  }