@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
@@ -4,6 +4,7 @@ import {
4
4
  spawnSync,
5
5
  type ChildProcess,
6
6
  } from "node:child_process";
7
+ import { createHash } from "node:crypto";
7
8
  import {
8
9
  closeSync,
9
10
  existsSync,
@@ -14,18 +15,29 @@ import {
14
15
  writeFileSync,
15
16
  } from "node:fs";
16
17
  import { createRequire } from "node:module";
18
+ import { networkInterfaces } from "node:os";
17
19
  import { dirname, join } from "node:path";
18
20
 
19
- import { GATEWAY_PORT } from "./constants.js";
21
+ import { cloudAssistantHubUrl } from "@vellumai/environments";
22
+
23
+ import {
24
+ getAssistantDisplayName,
25
+ lookupAssistantByIdentifier,
26
+ } from "./assistant-config.js";
27
+ import { getCurrentEnvironment } from "./environments/resolve.js";
28
+ import {
29
+ isAssistantFeatureFlagEnabled,
30
+ WEB_REMOTE_INGRESS_FLAG,
31
+ } from "./feature-flags.js";
20
32
  import { waitForDaemonReady } from "./http-client.js";
21
33
  import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
22
34
 
23
35
  /**
24
- * CLI-managed nginx reverse proxy that fronts the gateway for remote web
25
- * ingress: browser → tunnel (TLS) → nginx@127.0.0.1 → gateway@127.0.0.1.
36
+ * CLI-managed nginx reverse proxy that fronts the gateway as the canonical
37
+ * tunnel target: browser → tunnel (TLS) → nginx@127.0.0.1 → gateway@127.0.0.1.
26
38
  *
27
- * While this proxy is running, `vellum tunnel` targets nginx's loopback listen
28
- * port instead of the gateway port.
39
+ * `vellum tunnel` (and the wake restore path) bring this edge up via
40
+ * `ensureTunnelEdge` and always front its loopback listen port.
29
41
  */
30
42
 
31
43
  export const DEFAULT_NGINX_INGRESS_PORT = 7840;
@@ -117,39 +129,120 @@ function gatewayProxyBlock(gatewayPort: number): string {
117
129
  proxy_set_header Connection $connection_upgrade;`;
118
130
  }
119
131
 
132
+ /**
133
+ * Sensitive local-only routes the edge must never expose to the internet,
134
+ * regardless of whether the SPA is being served.
135
+ */
136
+ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
137
+ location = /auth/token/ { return 404; }
138
+ location = /v1/pair { return 404; }
139
+ location = /v1/pair/ { return 404; }
140
+ location = /v1/pair/web-init { return 404; }
141
+ location = /v1/pair/web-init/ { return 404; }
142
+ location = /v1/devices { return 404; }
143
+ location = /v1/devices/ { return 404; }
144
+ location = /v1/devices/revoke { return 404; }
145
+ location = /v1/devices/revoke/ { return 404; }
146
+ location = /v1/guardian/init { return 404; }
147
+ location = /v1/guardian/init/ { return 404; }
148
+ location = /v1/guardian/reset-bootstrap { return 404; }
149
+ location = /v1/guardian/reset-bootstrap/ { return 404; }
150
+ location = /v1/remote-web/pairing-verification { return 404; }
151
+ location = /v1/remote-web/pairing-verification/ { return 404; }
152
+ location ^~ /assistant/__local/ { return 404; }
153
+ location ^~ /assistant/__gateway/ { return 404; }
154
+ location ^~ /assistant/__gateway-paired/ { return 404; }`;
155
+
120
156
  export interface RemoteWebIngressOptions {
121
157
  webDistDir: string;
122
158
  indexHtmlPath?: string;
123
159
  config?: Record<string, unknown>;
160
+ /** Serving assistant's display name, stamped into the served config so
161
+ * remote clients can label this origin. Absent in older served configs. */
162
+ assistantName?: string;
163
+ /** Cloud web SPA base the remote client can hand this origin to (see
164
+ * `cloudWebHubUrl`). Absent in older served configs. */
165
+ hubUrl?: string;
166
+ }
167
+
168
+ /**
169
+ * Cloud web SPA base URL for a build environment. The mapping lives in
170
+ * `@vellumai/environments` (`cloudAssistantHubUrl`) and is shared with the
171
+ * Capacitor shell's `server.url`, so the two consumers cannot drift.
172
+ */
173
+ export function cloudWebHubUrl(env: string | undefined): string {
174
+ return cloudAssistantHubUrl(env);
124
175
  }
125
176
 
126
177
  function remoteWebIngressConfig(
127
- config: Record<string, unknown> | undefined,
178
+ opts: Pick<RemoteWebIngressOptions, "config" | "assistantName" | "hubUrl">,
128
179
  ): Record<string, unknown> {
129
180
  return {
130
181
  mode: "remote-gateway",
131
182
  apiBaseUrl: "/v1",
132
183
  platformDisabled: true,
133
184
  disablePlatform: true,
134
- ...config,
185
+ ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
186
+ ...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
187
+ ...opts.config,
135
188
  };
136
189
  }
137
190
 
191
+ /**
192
+ * Part of edge identity: a detached edge is reused only while its recorded
193
+ * fingerprint matches, so this must change whenever the generated index or
194
+ * nginx template does.
195
+ */
196
+ const EDGE_TEMPLATE_VERSION = 2;
197
+
198
+ /**
199
+ * Stable fingerprint of the SPA config injected into the served index and
200
+ * `/assistant/__config`, plus the template that renders them. Recorded
201
+ * alongside the edge state so a reuse decision can tell whether a running edge
202
+ * already serves the requested config (see `IngressState.remoteWebConfigHash`).
203
+ */
204
+ function remoteWebConfigFingerprint(config: Record<string, unknown>): string {
205
+ return createHash("sha256")
206
+ .update(JSON.stringify({ template: EDGE_TEMPLATE_VERSION, config }))
207
+ .digest("hex");
208
+ }
209
+
138
210
  function safeScriptJson(value: unknown): string {
139
211
  return JSON.stringify(value)
140
212
  .replace(/</g, "\\u003c")
141
213
  .replace(/>/g, "\\u003e");
142
214
  }
143
215
 
216
+ /**
217
+ * Preloading the whole chunk graph opens ~290 tunnel connections on a cold
218
+ * load, and one dropped request blanks the app before React can report it.
219
+ * These are hints only; the entry module still pulls what it needs.
220
+ */
221
+ function stripModulePreloads(html: string): string {
222
+ return html.replace(/<link[^>]+rel="modulepreload"[^>]*>\s*/g, "");
223
+ }
224
+
144
225
  export function buildRemoteWebIndexHtml(
145
226
  rawHtml: string,
146
227
  config: Record<string, unknown>,
147
228
  ): string {
229
+ const html = stripModulePreloads(rawHtml);
148
230
  const script = `<script>window.__VELLUM_CONFIG__=${safeScriptJson(config)}</script>`;
149
- if (rawHtml.includes("</head>")) {
150
- return rawHtml.replace("</head>", `${script}</head>`);
231
+ if (html.includes("</head>")) {
232
+ return html.replace("</head>", `${script}</head>`);
151
233
  }
152
- return `${script}${rawHtml}`;
234
+ return `${script}${html}`;
235
+ }
236
+
237
+ /**
238
+ * Whether the host has an IPv6 loopback to bind. False on Linux installs with
239
+ * IPv6 disabled, where emitting the listener would make nginx exit at startup
240
+ * rather than fall back to IPv4.
241
+ */
242
+ export function hasIpv6Loopback(): boolean {
243
+ return Object.values(networkInterfaces())
244
+ .flatMap((addrs) => addrs ?? [])
245
+ .some((addr) => addr.address === "::1");
153
246
  }
154
247
 
155
248
  /**
@@ -159,17 +252,27 @@ export function buildIngressNginxConfig(opts: {
159
252
  gatewayPort: number;
160
253
  listenPort: number;
161
254
  remoteWebIngress?: RemoteWebIngressOptions;
255
+ /** Emit the `[::1]` listener. Off where the host has no IPv6 loopback,
256
+ * since nginx exits at startup when it cannot bind a listen address. */
257
+ ipv6Loopback?: boolean;
162
258
  }): string {
163
259
  const proxyBlock = gatewayProxyBlock(opts.gatewayPort);
260
+ // A tunnel agent pointed at "localhost" reaches ::1 first on macOS, so an
261
+ // IPv4-only bind refuses whichever share of a burst resolves that way.
262
+ const ipv6Listen = opts.ipv6Loopback
263
+ ? ` listen [::1]:${opts.listenPort};\n`
264
+ : "";
164
265
  const remoteWebIngress = opts.remoteWebIngress;
165
266
  const serverLocations = remoteWebIngress
166
267
  ? buildRemoteWebIngressLocations({
167
268
  gatewayPort: opts.gatewayPort,
168
269
  webDistDir: remoteWebIngress.webDistDir,
169
270
  indexHtmlPath: remoteWebIngress.indexHtmlPath,
170
- config: remoteWebIngressConfig(remoteWebIngress.config),
271
+ config: remoteWebIngressConfig(remoteWebIngress),
171
272
  })
172
- : ` location / {
273
+ : `${DENYLIST_LOCATIONS}
274
+
275
+ location / {
173
276
  ${proxyBlock}
174
277
  }`;
175
278
 
@@ -208,7 +311,7 @@ http {
208
311
 
209
312
  server {
210
313
  listen 127.0.0.1:${opts.listenPort};
211
- client_max_body_size 512m;
314
+ ${ipv6Listen} client_max_body_size 512m;
212
315
 
213
316
  # This edge sits behind a TLS-terminating front (tunnel or tailscale serve),
214
317
  # so redirects must be relative: emit "Location: /assistant/" and let the
@@ -236,24 +339,7 @@ function buildRemoteWebIngressLocations(opts: {
236
339
  opts.indexHtmlPath ?? join(opts.webDistDir, "index.html");
237
340
  const configJson = JSON.stringify(opts.config);
238
341
 
239
- return ` location = /auth/token { return 404; }
240
- location = /auth/token/ { return 404; }
241
- location = /v1/pair { return 404; }
242
- location = /v1/pair/ { return 404; }
243
- location = /v1/pair/web-init { return 404; }
244
- location = /v1/pair/web-init/ { return 404; }
245
- location = /v1/devices { return 404; }
246
- location = /v1/devices/ { return 404; }
247
- location = /v1/devices/revoke { return 404; }
248
- location = /v1/devices/revoke/ { return 404; }
249
- location = /v1/guardian/init { return 404; }
250
- location = /v1/guardian/init/ { return 404; }
251
- location = /v1/guardian/reset-bootstrap { return 404; }
252
- location = /v1/guardian/reset-bootstrap/ { return 404; }
253
- location = /v1/remote-web/pairing-verification { return 404; }
254
- location = /v1/remote-web/pairing-verification/ { return 404; }
255
- location ^~ /assistant/__local/ { return 404; }
256
- location ^~ /assistant/__gateway/ { return 404; }
342
+ return `${DENYLIST_LOCATIONS}
257
343
 
258
344
  location = /healthz {
259
345
  ${proxyBlock}
@@ -263,6 +349,10 @@ ${proxyBlock}
263
349
  ${proxyBlock}
264
350
  }
265
351
 
352
+ location ^~ /webhooks/ {
353
+ ${proxyBlock}
354
+ }
355
+
266
356
  location = /assistant {
267
357
  return 302 /assistant/;
268
358
  }
@@ -395,23 +485,58 @@ export function isIngressRunning(workspaceDir: string): boolean {
395
485
  return getIngressPid(workspaceDir) !== null;
396
486
  }
397
487
 
398
- interface IngressState {
488
+ export interface IngressState {
399
489
  listenPort: number;
490
+ /**
491
+ * Edge mode: SPA + proxy (true) or webhooks-only proxy (false). A persisted
492
+ * record without this field represents an SPA edge.
493
+ */
494
+ includeWebApp: boolean;
495
+ /**
496
+ * Gateway port the edge's proxy_pass targets. Undefined in state records
497
+ * that predate the field; callers treat an unknown port as unverified and
498
+ * restart the edge so the running config provably targets the requested
499
+ * port and the state is stamped for future comparisons.
500
+ */
501
+ gatewayPort?: number;
502
+ /**
503
+ * Fingerprint of the SPA config injected into the served index. Undefined
504
+ * for webhooks-only edges and for SPA records that predate the field; an
505
+ * SPA edge without a recorded fingerprint is treated as drifted and
506
+ * restarted so the served index provably carries the requested config.
507
+ */
508
+ remoteWebConfigHash?: string;
400
509
  }
401
510
 
402
- function readIngressState(workspaceDir: string): IngressState | null {
511
+ export function readIngressState(workspaceDir: string): IngressState | null {
403
512
  const config = loadRawConfig(workspaceDir);
404
513
  const ingress = config.ingress as Record<string, unknown> | undefined;
405
514
  const nginx = ingress?.nginx as Record<string, unknown> | undefined;
406
515
  const listenPort = nginx?.listenPort;
407
516
  if (typeof listenPort !== "number") return null;
408
- return { listenPort };
517
+ const gatewayPort = nginx?.gatewayPort;
518
+ const remoteWebConfigHash = nginx?.remoteWebConfigHash;
519
+ return {
520
+ listenPort,
521
+ includeWebApp: nginx?.includeWebApp !== false,
522
+ ...(typeof gatewayPort === "number" ? { gatewayPort } : {}),
523
+ ...(typeof remoteWebConfigHash === "string" ? { remoteWebConfigHash } : {}),
524
+ };
409
525
  }
410
526
 
411
527
  function saveIngressState(workspaceDir: string, state: IngressState): void {
412
528
  const config = loadRawConfig(workspaceDir);
413
529
  const ingress = (config.ingress ?? {}) as Record<string, unknown>;
414
- ingress.nginx = { listenPort: state.listenPort };
530
+ ingress.nginx = {
531
+ listenPort: state.listenPort,
532
+ includeWebApp: state.includeWebApp,
533
+ ...(state.gatewayPort !== undefined
534
+ ? { gatewayPort: state.gatewayPort }
535
+ : {}),
536
+ ...(state.remoteWebConfigHash !== undefined
537
+ ? { remoteWebConfigHash: state.remoteWebConfigHash }
538
+ : {}),
539
+ };
415
540
  config.ingress = ingress;
416
541
  saveRawConfig(workspaceDir, config);
417
542
  }
@@ -447,7 +572,7 @@ export function startIngressNginx(opts: {
447
572
  const remoteWebIngress = opts.remoteWebIngress
448
573
  ? {
449
574
  ...opts.remoteWebIngress,
450
- config: remoteWebIngressConfig(opts.remoteWebIngress.config),
575
+ config: remoteWebIngressConfig(opts.remoteWebIngress),
451
576
  indexHtmlPath: join(paths.dir, "assistant-index.html"),
452
577
  }
453
578
  : undefined;
@@ -467,6 +592,7 @@ export function startIngressNginx(opts: {
467
592
  gatewayPort: opts.gatewayPort,
468
593
  listenPort: opts.listenPort,
469
594
  remoteWebIngress,
595
+ ipv6Loopback: hasIpv6Loopback(),
470
596
  }),
471
597
  );
472
598
 
@@ -478,7 +604,18 @@ export function startIngressNginx(opts: {
478
604
  );
479
605
  closeSync(fd);
480
606
 
481
- saveIngressState(opts.workspaceDir, { listenPort: opts.listenPort });
607
+ saveIngressState(opts.workspaceDir, {
608
+ listenPort: opts.listenPort,
609
+ includeWebApp: opts.remoteWebIngress !== undefined,
610
+ gatewayPort: opts.gatewayPort,
611
+ ...(remoteWebIngress
612
+ ? {
613
+ remoteWebConfigHash: remoteWebConfigFingerprint(
614
+ remoteWebIngress.config,
615
+ ),
616
+ }
617
+ : {}),
618
+ });
482
619
  return child;
483
620
  }
484
621
 
@@ -545,64 +682,165 @@ export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
545
682
  export const INGRESS_READY_TIMEOUT_MS = 5_000;
546
683
 
547
684
  /**
548
- * Outcome of an attempt to bring up the remote-web nginx ingress edge. Callers
549
- * render their own messaging per variant: `nginx-ingress up` prints
550
- * install/build guidance and exits non-zero, while the wake restore path warns
551
- * and continues.
685
+ * Settle budget for the spawned nginx to prove port ownership: a healthy
686
+ * master records its pid under our prefix within milliseconds, while one that
687
+ * lost the bind exits. Bounds the wait for whichever happens first.
688
+ */
689
+ // The settle window must outlast nginx's internal bind-retry loop (5 attempts
690
+ // with 500ms sleeps) so a contested bind resolves to a child exit rather than
691
+ // a timeout while the child is still retrying.
692
+ const OWNERSHIP_SETTLE_TIMEOUT_MS = 4_000;
693
+ const OWNERSHIP_SETTLE_INTERVAL_MS = 100;
694
+
695
+ /**
696
+ * Outcome of an attempt to bring up the nginx ingress edge. Callers render
697
+ * their own messaging per variant: `ensureTunnelEdge` maps failure variants to
698
+ * thrown errors with actionable text, which `vellum tunnel` and
699
+ * `nginx-ingress up` print before exiting non-zero while the wake restore path
700
+ * warns and continues.
552
701
  */
553
702
  export type StartRemoteWebIngressResult =
554
703
  | {
555
704
  status: "started";
556
705
  listenPort: number;
557
- webDistDir: string;
706
+ /** SPA dist directory served by the edge; null in webhooks-only mode. */
707
+ webDistDir: string | null;
558
708
  version: string;
559
709
  }
560
- | { status: "already-running"; listenPort: number }
710
+ | {
711
+ status: "already-running";
712
+ /** Recorded listen port of the running edge (requested port when no state is recorded). */
713
+ listenPort: number;
714
+ /** Recorded edge mode; a running edge without a state record reports the SPA default. */
715
+ includeWebApp: boolean;
716
+ /** Recorded gateway upstream; undefined when the record predates the field. */
717
+ gatewayPort?: number;
718
+ /**
719
+ * Set when the surviving edge matches the requested mode and gateway
720
+ * port but serves a different injected SPA config than requested (a
721
+ * restart was attempted and failed), so the served index is stale.
722
+ */
723
+ staleRemoteWebConfig?: true;
724
+ }
561
725
  | { status: "nginx-missing" }
562
726
  | { status: "web-dist-missing" }
563
- | { status: "unreachable"; listenPort: number; logPath: string };
727
+ | { status: "unreachable"; listenPort: number; logPath: string }
728
+ | { status: "port-conflict"; listenPort: number; logPath: string };
564
729
 
565
730
  /**
566
- * Generate the nginx config and start the remote-web ingress edge, then probe
567
- * /healthz through it to prove the ingress → gateway path is live. A spawned
568
- * but unreachable nginx is rolled back so a failed attempt leaves no half-up
569
- * edge behind.
731
+ * Generate the nginx config and start the remote-web ingress edge, probe
732
+ * /healthz through it to prove the ingress → gateway path is live, and confirm
733
+ * via the recorded pid file that the spawned nginx owns the listen port. A
734
+ * spawned but unreachable or unowned nginx is rolled back so a failed attempt
735
+ * leaves no half-up edge behind.
736
+ *
737
+ * An edge already running in the requested mode, against the requested
738
+ * gateway port, and serving the requested injected SPA config short-circuits
739
+ * as `already-running`; one running in the other mode (SPA vs webhooks-only),
740
+ * against a different gateway port, or serving a drifted SPA config (e.g. a
741
+ * renamed assistant or an updated hub URL) is stopped and restarted with the
742
+ * requested config.
570
743
  *
571
744
  * Pure mechanism: it performs no console output and never exits the process, so
572
- * both the `nginx-ingress up` command and the wake restore path can share one
573
- * implementation and map the returned result to their own UX.
745
+ * every edge caller (`vellum tunnel`, `nginx-ingress up`, the wake restore
746
+ * path) can share one implementation and map the returned result to its own UX.
574
747
  */
575
748
  export async function startRemoteWebIngress(opts: {
576
749
  workspaceDir: string;
577
750
  gatewayPort: number;
578
751
  listenPort?: number;
579
752
  readyTimeoutMs?: number;
753
+ /**
754
+ * Serve the web SPA from the edge (default true). When false the web-dist
755
+ * preflight is skipped and the edge only proxies gateway traffic behind the
756
+ * sensitive-route denylist (webhooks-only mode).
757
+ */
758
+ includeWebApp?: boolean;
759
+ /**
760
+ * Serving assistant's display name, stamped into the served remote-web
761
+ * config (`__VELLUM_CONFIG__.assistantName`) so remote clients can label
762
+ * this origin. Omitted when unknown; consumers tolerate its absence.
763
+ */
764
+ assistantName?: string;
580
765
  /**
581
766
  * Invoked once, after every preflight check passes and immediately before
582
767
  * nginx is spawned, so callers can emit their own "starting" progress line
583
- * with the resolved version/dist/port. Never fires on a preflight bail-out
584
- * (nginx-missing, already-running, web-dist-missing).
768
+ * with the resolved version/dist/port (webDistDir is null in webhooks-only
769
+ * mode). Never fires on a preflight bail-out (nginx-missing,
770
+ * already-running, web-dist-missing).
585
771
  */
586
772
  onStarting?: (info: {
587
773
  version: string;
588
- webDistDir: string;
774
+ webDistDir: string | null;
589
775
  listenPort: number;
590
776
  }) => void;
591
777
  }): Promise<StartRemoteWebIngressResult> {
592
778
  const listenPort = opts.listenPort ?? getNginxIngressPort();
779
+ const includeWebApp = opts.includeWebApp ?? true;
593
780
 
594
781
  const version = getNginxVersion();
595
782
  if (!version) {
596
783
  return { status: "nginx-missing" };
597
784
  }
598
785
 
599
- if (isIngressRunning(opts.workspaceDir)) {
600
- return { status: "already-running", listenPort };
786
+ const running = isIngressRunning(opts.workspaceDir);
787
+ const recorded = running ? readIngressState(opts.workspaceDir) : null;
788
+ const recordedMode = recorded?.includeWebApp ?? true;
789
+ // The SPA config the served index must carry, computed up front so the
790
+ // reuse decision and the actual spawn share one value and cannot drift.
791
+ const spaOptions = includeWebApp
792
+ ? {
793
+ hubUrl: cloudWebHubUrl(getCurrentEnvironment().name),
794
+ ...(opts.assistantName ? { assistantName: opts.assistantName } : {}),
795
+ }
796
+ : undefined;
797
+ const requestedConfigHash = spaOptions
798
+ ? remoteWebConfigFingerprint(remoteWebIngressConfig(spaOptions))
799
+ : undefined;
800
+ // Both sides are undefined in webhooks-only mode; an SPA record without a
801
+ // fingerprint predates the field and counts as drifted (see IngressState).
802
+ const configMatches = recorded?.remoteWebConfigHash === requestedConfigHash;
803
+ const alreadyRunning = (): StartRemoteWebIngressResult => ({
804
+ status: "already-running",
805
+ listenPort: recorded?.listenPort ?? listenPort,
806
+ includeWebApp: recordedMode,
807
+ ...(recorded?.gatewayPort !== undefined
808
+ ? { gatewayPort: recorded.gatewayPort }
809
+ : {}),
810
+ ...(recordedMode === includeWebApp &&
811
+ recorded?.gatewayPort === opts.gatewayPort &&
812
+ !configMatches
813
+ ? { staleRemoteWebConfig: true as const }
814
+ : {}),
815
+ });
816
+ // An unknown recorded gateway port is unverified (see IngressState).
817
+ if (
818
+ running &&
819
+ recordedMode === includeWebApp &&
820
+ recorded?.gatewayPort === opts.gatewayPort &&
821
+ configMatches
822
+ ) {
823
+ return alreadyRunning();
601
824
  }
602
825
 
603
- const webDistDir = findWebDistDir();
604
- if (!webDistDir) {
605
- return { status: "web-dist-missing" };
826
+ let webDistDir: string | null = null;
827
+ if (includeWebApp) {
828
+ webDistDir = findWebDistDir();
829
+ if (!webDistDir) {
830
+ return { status: "web-dist-missing" };
831
+ }
832
+ }
833
+
834
+ // The running edge serves the other mode, targets a different gateway
835
+ // port, or injects a stale SPA config; restart it with the requested
836
+ // config. A false stop can mean the old edge exited on its own after the
837
+ // check above, so recheck liveness and only bail when it is still serving.
838
+ if (
839
+ running &&
840
+ !(await stopIngressNginx(opts.workspaceDir)) &&
841
+ isIngressRunning(opts.workspaceDir)
842
+ ) {
843
+ return alreadyRunning();
606
844
  }
607
845
 
608
846
  opts.onStarting?.({ version, webDistDir, listenPort });
@@ -611,7 +849,11 @@ export async function startRemoteWebIngress(opts: {
611
849
  workspaceDir: opts.workspaceDir,
612
850
  gatewayPort: opts.gatewayPort,
613
851
  listenPort,
614
- remoteWebIngress: { webDistDir },
852
+ remoteWebIngress: webDistDir ? { webDistDir, ...spaOptions } : undefined,
853
+ });
854
+ let exited = false;
855
+ child.once("exit", () => {
856
+ exited = true;
615
857
  });
616
858
  child.unref();
617
859
 
@@ -621,32 +863,237 @@ export async function startRemoteWebIngress(opts: {
621
863
  listenPort,
622
864
  opts.readyTimeoutMs ?? INGRESS_READY_TIMEOUT_MS,
623
865
  );
624
- if (!ready) {
866
+ const rollback = async (
867
+ status: "port-conflict" | "unreachable",
868
+ ): Promise<StartRemoteWebIngressResult> => {
625
869
  const { logPath } = getIngressPaths(opts.workspaceDir);
626
870
  await stopIngressNginx(opts.workspaceDir);
627
- return { status: "unreachable", listenPort, logPath };
871
+ return { status, listenPort, logPath };
872
+ };
873
+ const childExited = (): boolean => exited || child.exitCode !== null;
874
+ // nginx runs `daemon off`, so the spawned process is the master and stays
875
+ // alive while the edge serves. An early exit means startup failed: a dead
876
+ // spawn is reported as a port conflict even when the probe succeeded, since
877
+ // that probe reached some other process bound to the port (e.g. another
878
+ // assistant's edge), not this one.
879
+ if (!ready) {
880
+ return rollback(childExited() ? "port-conflict" : "unreachable");
881
+ }
882
+ // A successful probe alone does not prove ownership either: a healthy edge
883
+ // from another workspace answers /healthz before our master finishes failing
884
+ // its bind. A master that won the bind records its pid under our prefix, so
885
+ // settle-poll until the spawn either exits (lost the bind) or that pid file
886
+ // names a live ingress nginx of ours (owns the port).
887
+ const deadline = Date.now() + OWNERSHIP_SETTLE_TIMEOUT_MS;
888
+ for (;;) {
889
+ if (childExited()) {
890
+ break;
891
+ }
892
+ if (getIngressPid(opts.workspaceDir) !== null) {
893
+ return { status: "started", listenPort, webDistDir, version };
894
+ }
895
+ if (Date.now() >= deadline) {
896
+ // The child is alive but never proved ownership. Kill it before rolling
897
+ // back: it may still be inside nginx's bind-retry loop, and an orphan
898
+ // that later wins the bind would serve with no recorded state.
899
+ try {
900
+ child.kill("SIGTERM");
901
+ } catch {
902
+ // Already gone; the rollback below clears any remaining state.
903
+ }
904
+ break;
905
+ }
906
+ await new Promise((resolve) =>
907
+ setTimeout(resolve, OWNERSHIP_SETTLE_INTERVAL_MS),
908
+ );
628
909
  }
910
+ return rollback("port-conflict");
911
+ }
629
912
 
630
- return { status: "started", listenPort, webDistDir, version };
913
+ /** Retry policy for the `web-remote-ingress` flag lookup. */
914
+ export interface FlagRetryPolicy {
915
+ attempts: number;
916
+ intervalMs: number;
631
917
  }
632
918
 
633
919
  /**
634
- * Resolve the local port a tunnel should target: the nginx ingress when it is
635
- * recorded AND its process is alive, otherwise the gateway port directly
636
- * (unchanged behavior when the proxy is not running).
920
+ * Resolve the edge mode for an assistant: the `web-remote-ingress` flag selects
921
+ * the SPA edge when enabled and the webhooks-only edge when disabled. The
922
+ * lookup requires a reachable assistant; `flagRetry` rides out a gateway that
923
+ * is still starting by retrying thrown lookups (a resolved `false` is a real
924
+ * answer, not a retry). When the budget is spent the last error throws with a
925
+ * wake hint.
637
926
  */
638
- export function resolveTunnelTargetPort(
639
- workspaceDir: string,
640
- gatewayPort: number = GATEWAY_PORT,
641
- opts: { preferNginxIngress?: boolean } = {},
642
- ): { port: number; viaIngress: boolean } {
643
- if (opts.preferNginxIngress === false) {
644
- return { port: gatewayPort, viaIngress: false };
927
+ async function resolveEdgeIncludesWebApp(
928
+ assistantId: string,
929
+ gatewayPort: number,
930
+ flagRetry?: FlagRetryPolicy,
931
+ ): Promise<boolean> {
932
+ const attempts = Math.max(1, flagRetry?.attempts ?? 1);
933
+ let lastError: unknown;
934
+ for (let attempt = 1; attempt <= attempts; attempt++) {
935
+ try {
936
+ return await isAssistantFeatureFlagEnabled(
937
+ assistantId,
938
+ WEB_REMOTE_INGRESS_FLAG,
939
+ { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
940
+ );
941
+ } catch (err) {
942
+ lastError = err;
943
+ if (attempt < attempts) {
944
+ await new Promise((resolve) =>
945
+ setTimeout(resolve, flagRetry?.intervalMs ?? 0),
946
+ );
947
+ }
948
+ }
645
949
  }
950
+ throw new Error(
951
+ `Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag before starting the edge. Is the assistant running? Try \`vellum wake\` and retry. ${
952
+ lastError instanceof Error ? lastError.message : String(lastError)
953
+ }`,
954
+ );
955
+ }
956
+
957
+ /**
958
+ * Display name recorded for the assistant in the CLI lockfile; undefined when
959
+ * no entry matches, so the served config omits the label rather than guessing.
960
+ */
961
+ function lockfileAssistantName(assistantId: string): string | undefined {
962
+ const result = lookupAssistantByIdentifier(assistantId);
963
+ return result.status === "found"
964
+ ? getAssistantDisplayName(result.entry)
965
+ : undefined;
966
+ }
967
+
968
+ /** User-facing label for the edge mode, shared by every edge status line. */
969
+ export function formatEdgeMode(includesWebApp: boolean): string {
970
+ return includesWebApp ? "remote web + webhooks" : "webhooks only";
971
+ }
972
+
973
+ /** Resolved edge a tunnel (or bring-your-own HTTPS front) should target. */
974
+ export interface TunnelEdge {
975
+ /** Loopback listen port the HTTPS front should forward to. */
976
+ port: number;
977
+ /** True when this call started the edge, false when a running one was reused. */
978
+ started: boolean;
979
+ includesWebApp: boolean;
980
+ }
646
981
 
647
- const state = readIngressState(workspaceDir);
648
- if (state && isIngressRunning(workspaceDir)) {
649
- return { port: state.listenPort, viaIngress: true };
982
+ /**
983
+ * Bring up the nginx edge as the canonical tunnel target and return the listen
984
+ * port a tunnel should front.
985
+ *
986
+ * The `web-remote-ingress` flag picks the edge mode (enabled: SPA + gateway
987
+ * proxy, disabled: webhooks-only proxy); an entry without an assistant id
988
+ * cannot have the flag verified and gets the webhooks-only edge. The resolved
989
+ * mode is always delegated to `startRemoteWebIngress`, which reuses a running
990
+ * edge that already serves that mode, gateway port, and injected SPA config
991
+ * and restarts one that drifted in any respect, so the returned port always
992
+ * fronts the flag-resolved config. `started` is false when a matching edge was
993
+ * reused; a drifted edge that survives the restart attempt throws rather than
994
+ * reporting the wrong config. Failures throw with actionable install or
995
+ * diagnostic text.
996
+ */
997
+ export async function ensureTunnelEdge(opts: {
998
+ assistantId: string | undefined;
999
+ workspaceDir: string;
1000
+ gatewayPort: number;
1001
+ /** Retries thrown flag lookups (e.g. a still-starting gateway); default one attempt. */
1002
+ flagRetry?: FlagRetryPolicy;
1003
+ /** Forwarded to `startRemoteWebIngress` for caller progress output. */
1004
+ onStarting?: (info: {
1005
+ version: string;
1006
+ webDistDir: string | null;
1007
+ listenPort: number;
1008
+ }) => void;
1009
+ }): Promise<TunnelEdge> {
1010
+ const includeWebApp = opts.assistantId
1011
+ ? await resolveEdgeIncludesWebApp(
1012
+ opts.assistantId,
1013
+ opts.gatewayPort,
1014
+ opts.flagRetry,
1015
+ )
1016
+ : false;
1017
+
1018
+ const assistantName =
1019
+ includeWebApp && opts.assistantId
1020
+ ? lockfileAssistantName(opts.assistantId)
1021
+ : undefined;
1022
+
1023
+ const result = await startRemoteWebIngress({
1024
+ workspaceDir: opts.workspaceDir,
1025
+ gatewayPort: opts.gatewayPort,
1026
+ includeWebApp,
1027
+ ...(assistantName ? { assistantName } : {}),
1028
+ ...(opts.onStarting ? { onStarting: opts.onStarting } : {}),
1029
+ });
1030
+
1031
+ switch (result.status) {
1032
+ case "started":
1033
+ return {
1034
+ port: result.listenPort,
1035
+ started: true,
1036
+ includesWebApp: includeWebApp,
1037
+ };
1038
+ case "already-running": {
1039
+ // `already-running` also covers a drifted edge whose restart failed, so
1040
+ // trust the recorded state it carries over the requested config.
1041
+ if (result.includeWebApp !== includeWebApp) {
1042
+ const describe = (spa: boolean) => (spa ? "web app" : "webhooks-only");
1043
+ throw new Error(
1044
+ `The nginx edge is still running in ${describe(result.includeWebApp)} mode ` +
1045
+ `and could not be restarted in ${describe(includeWebApp)} mode. ` +
1046
+ "Run `vellum nginx-ingress down` and retry.",
1047
+ );
1048
+ }
1049
+ if (result.gatewayPort !== opts.gatewayPort) {
1050
+ const upstream =
1051
+ result.gatewayPort !== undefined
1052
+ ? `still proxying gateway port ${result.gatewayPort}`
1053
+ : "proxying an unknown gateway port";
1054
+ throw new Error(
1055
+ `The nginx edge is ${upstream} ` +
1056
+ `and could not be restarted against port ${opts.gatewayPort}. ` +
1057
+ "Run `vellum nginx-ingress down` and retry.",
1058
+ );
1059
+ }
1060
+ if (result.staleRemoteWebConfig) {
1061
+ throw new Error(
1062
+ "The nginx edge is still serving an outdated remote web config " +
1063
+ "and could not be restarted with the updated one. " +
1064
+ "Run `vellum nginx-ingress down` and retry.",
1065
+ );
1066
+ }
1067
+ return {
1068
+ port: result.listenPort,
1069
+ started: false,
1070
+ includesWebApp: result.includeWebApp,
1071
+ };
1072
+ }
1073
+ case "nginx-missing":
1074
+ throw new Error(
1075
+ "nginx is not installed, so the tunnel edge cannot start. " +
1076
+ "Install it (macOS: `brew install nginx`, Linux: `sudo apt install nginx`) " +
1077
+ "or point NGINX_BIN at an existing binary.",
1078
+ );
1079
+ case "web-dist-missing":
1080
+ throw new Error(
1081
+ "Unable to locate built web assets for the remote web edge. " +
1082
+ "Build the SPA (`cd clients/web && VITE_PLATFORM_MODE=false bun run build`) " +
1083
+ "or install @vellumai/web so its packaged dist directory is available.",
1084
+ );
1085
+ case "unreachable":
1086
+ throw new Error(
1087
+ `nginx edge did not become reachable on 127.0.0.1:${result.listenPort}. ` +
1088
+ `Check the nginx log: ${result.logPath}`,
1089
+ );
1090
+ case "port-conflict":
1091
+ throw new Error(
1092
+ `nginx edge exited on startup, most likely because 127.0.0.1:${result.listenPort} ` +
1093
+ "is already in use (for example by another assistant's tunnel edge). " +
1094
+ "Stop whatever is bound to that port, or pick a different edge port by " +
1095
+ "setting the VELLUM_NGINX_INGRESS_PORT environment variable, then retry. " +
1096
+ `Check the nginx log: ${result.logPath}`,
1097
+ );
650
1098
  }
651
- return { port: gatewayPort, viaIngress: false };
652
1099
  }