@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.
- package/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
25
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
...
|
|
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 (
|
|
150
|
-
return
|
|
231
|
+
if (html.includes("</head>")) {
|
|
232
|
+
return html.replace("</head>", `${script}</head>`);
|
|
151
233
|
}
|
|
152
|
-
return `${script}${
|
|
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
|
|
271
|
+
config: remoteWebIngressConfig(remoteWebIngress),
|
|
171
272
|
})
|
|
172
|
-
:
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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, {
|
|
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
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
|
|
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
|
-
|
|
706
|
+
/** SPA dist directory served by the edge; null in webhooks-only mode. */
|
|
707
|
+
webDistDir: string | null;
|
|
558
708
|
version: string;
|
|
559
709
|
}
|
|
560
|
-
| {
|
|
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,
|
|
567
|
-
* /healthz through it to prove the ingress → gateway path is live
|
|
568
|
-
*
|
|
569
|
-
*
|
|
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
|
-
*
|
|
573
|
-
* implementation and map the returned result to
|
|
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
|
|
584
|
-
* (nginx-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
|
-
|
|
600
|
-
|
|
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
|
-
|
|
604
|
-
if (
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
635
|
-
*
|
|
636
|
-
*
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
gatewayPort: number
|
|
641
|
-
|
|
642
|
-
):
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
}
|