@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
@@ -0,0 +1,291 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ import {
5
+ formatAssistantLookupError,
6
+ lookupAssistantByIdentifier,
7
+ resolveAssistant,
8
+ } from "../lib/assistant-config.js";
9
+ import type { AssistantEntry } from "../lib/assistant-config.js";
10
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
11
+ import { GATEWAY_PORT } from "../lib/constants.js";
12
+ import {
13
+ formatFeatureFlagGateMessage,
14
+ isAssistantFeatureFlagEnabled,
15
+ WEB_REMOTE_INGRESS_FLAG,
16
+ } from "../lib/feature-flags.js";
17
+ import { waitForDaemonReady } from "../lib/http-client.js";
18
+ import {
19
+ DEFAULT_NGINX_INGRESS_PORT,
20
+ findWebDistDir,
21
+ getIngressPaths,
22
+ getIngressPid,
23
+ getNginxIngressPort,
24
+ getNginxVersion,
25
+ isIngressRunning,
26
+ resolveTunnelTargetPort,
27
+ startIngressNginx,
28
+ stopIngressNginx,
29
+ } from "../lib/nginx-ingress.js";
30
+
31
+ const READY_TIMEOUT_MS = 5_000;
32
+
33
+ function printHelp(): void {
34
+ console.log("Usage: vellum nginx-ingress <subcommand> [<name>] [options]");
35
+ console.log("");
36
+ console.log(
37
+ "Manage the nginx web edge that serves the SPA and fronts the gateway",
38
+ );
39
+ console.log(
40
+ "for remote web access: browser → tunnel (TLS) → nginx@127.0.0.1.",
41
+ );
42
+ console.log("While nginx ingress is running, `vellum tunnel` targets it.");
43
+ console.log("");
44
+ console.log("Subcommands:");
45
+ console.log(" up Generate the nginx config and start the proxy");
46
+ console.log(" down Stop the proxy");
47
+ console.log(" status Show whether the proxy is running and where");
48
+ console.log("");
49
+ console.log("Arguments:");
50
+ console.log(
51
+ " <name> Name of the assistant (defaults to active or only local)",
52
+ );
53
+ console.log("");
54
+ console.log("Options:");
55
+ console.log(" --help, -h Show this help");
56
+ console.log("");
57
+ console.log("Environment:");
58
+ console.log(
59
+ ` VELLUM_NGINX_INGRESS_PORT nginx ingress loopback listen port (default ${DEFAULT_NGINX_INGRESS_PORT})`,
60
+ );
61
+ console.log(" NGINX_BIN Path to the nginx binary");
62
+ console.log("");
63
+ console.log("Examples:");
64
+ console.log(" $ vellum nginx-ingress up");
65
+ console.log(" $ vellum nginx-ingress status");
66
+ console.log(" $ vellum nginx-ingress down my-assistant");
67
+ console.log("");
68
+ console.log("Feature flags:");
69
+ console.log(
70
+ ` ${WEB_REMOTE_INGRESS_FLAG} must be enabled to start nginx ingress`,
71
+ );
72
+ }
73
+
74
+ interface NginxIngressTarget {
75
+ assistantId?: string;
76
+ workspaceDir: string;
77
+ gatewayPort: number;
78
+ }
79
+
80
+ function parsePortFromUrl(url: unknown): number | undefined {
81
+ if (typeof url !== "string" || !url.trim()) return undefined;
82
+ try {
83
+ const port = Number(new URL(url).port);
84
+ return Number.isInteger(port) && port > 0 && port <= 65535
85
+ ? port
86
+ : undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ function resolveEntryGatewayPort(entry: AssistantEntry | undefined): number {
93
+ return (
94
+ parsePortFromUrl(entry?.localUrl) ??
95
+ parsePortFromUrl(entry?.runtimeUrl) ??
96
+ GATEWAY_PORT
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Resolve which assistant nginx ingress fronts. Multi-instance hatches allocate
102
+ * per-assistant gateway ports and workspaces, so both must come from the
103
+ * resolved entry's resources. Entries without resources still record their
104
+ * reachable gateway URL, so derive the port from localUrl/runtimeUrl before
105
+ * falling back to the legacy default. Explicit names go through the shared
106
+ * identifier lookup (see cli/AGENTS.md "Assistant targeting convention") so
107
+ * display names resolve and ambiguous matches fail loudly.
108
+ */
109
+ export function resolveNginxIngressTarget(
110
+ assistantName: string | null,
111
+ ): NginxIngressTarget {
112
+ let entry: AssistantEntry | undefined;
113
+ if (assistantName) {
114
+ const result = lookupAssistantByIdentifier(assistantName);
115
+ if (result.status !== "found") {
116
+ throw new Error(formatAssistantLookupError(assistantName, result));
117
+ }
118
+ entry = result.entry;
119
+ } else {
120
+ entry = resolveAssistant() ?? undefined;
121
+ }
122
+ if (entry?.resources) {
123
+ return {
124
+ assistantId: entry.assistantId,
125
+ workspaceDir: join(entry.resources.instanceDir, ".vellum", "workspace"),
126
+ gatewayPort: entry.resources.gatewayPort,
127
+ };
128
+ }
129
+ return {
130
+ assistantId: entry?.assistantId,
131
+ workspaceDir:
132
+ process.env.VELLUM_WORKSPACE_DIR?.trim() ||
133
+ join(homedir(), ".vellum", "workspace"),
134
+ gatewayPort: resolveEntryGatewayPort(entry),
135
+ };
136
+ }
137
+
138
+ async function assertWebRemoteIngressEnabled(
139
+ target: NginxIngressTarget,
140
+ ): Promise<void> {
141
+ if (!target.assistantId) {
142
+ throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
143
+ }
144
+
145
+ let enabled: boolean;
146
+ try {
147
+ enabled = await isAssistantFeatureFlagEnabled(
148
+ target.assistantId,
149
+ WEB_REMOTE_INGRESS_FLAG,
150
+ { runtimeUrl: `http://127.0.0.1:${target.gatewayPort}` },
151
+ );
152
+ } catch (err) {
153
+ throw new Error(
154
+ `Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
155
+ err instanceof Error ? err.message : String(err)
156
+ }`,
157
+ );
158
+ }
159
+
160
+ if (!enabled) {
161
+ throw new Error(formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG));
162
+ }
163
+ }
164
+
165
+ async function up(target: NginxIngressTarget): Promise<void> {
166
+ const { workspaceDir, gatewayPort } = target;
167
+ const listenPort = getNginxIngressPort();
168
+
169
+ await assertWebRemoteIngressEnabled(target);
170
+
171
+ const version = getNginxVersion();
172
+ if (!version) {
173
+ console.error("Error: nginx is not installed.");
174
+ console.error("");
175
+ console.error("Install nginx:");
176
+ console.error(" macOS: brew install nginx");
177
+ console.error(" Linux: sudo apt install nginx");
178
+ console.error("");
179
+ console.error(
180
+ "Or point NGINX_BIN at an existing binary: NGINX_BIN=/path/to/nginx",
181
+ );
182
+ process.exit(1);
183
+ }
184
+
185
+ if (isIngressRunning(workspaceDir)) {
186
+ console.log("nginx ingress is already running.");
187
+ await status(target);
188
+ return;
189
+ }
190
+
191
+ const webDistDir = findWebDistDir();
192
+ if (!webDistDir) {
193
+ console.error(
194
+ "Error: unable to locate built web assets for remote web ingress.",
195
+ );
196
+ console.error("");
197
+ console.error("Build the SPA first:");
198
+ console.error(" cd apps/web && VITE_PLATFORM_MODE=false bun run build");
199
+ console.error("");
200
+ console.error(
201
+ "Or install @vellumai/web so its packaged dist directory is available.",
202
+ );
203
+ process.exit(1);
204
+ }
205
+
206
+ console.log(`Using ${version}`);
207
+ console.log(
208
+ `Starting nginx ingress on 127.0.0.1:${listenPort} → web ${webDistDir} + gateway 127.0.0.1:${gatewayPort}...`,
209
+ );
210
+
211
+ const child = startIngressNginx({
212
+ workspaceDir,
213
+ gatewayPort,
214
+ listenPort,
215
+ remoteWebIngress: { webDistDir },
216
+ });
217
+ child.unref();
218
+
219
+ // /healthz proxies through nginx to the gateway, so a 200 proves the whole
220
+ // ingress → gateway path works.
221
+ const ready = await waitForDaemonReady(listenPort, READY_TIMEOUT_MS);
222
+ if (!ready) {
223
+ const { logPath } = getIngressPaths(workspaceDir);
224
+ await stopIngressNginx(workspaceDir);
225
+ console.error(
226
+ `Error: nginx ingress did not become reachable on 127.0.0.1:${listenPort}.`,
227
+ );
228
+ console.error(`Check the nginx log: ${logPath}`);
229
+ process.exit(1);
230
+ }
231
+
232
+ console.log("");
233
+ console.log(`nginx ingress running: http://127.0.0.1:${listenPort}`);
234
+ console.log("");
235
+ console.log("Next steps:");
236
+ console.log(
237
+ " vellum tunnel --provider ngrok # tunnel now targets nginx ingress",
238
+ );
239
+ console.log(" vellum nginx-ingress down # stop the proxy");
240
+ }
241
+
242
+ async function down(target: NginxIngressTarget): Promise<void> {
243
+ const stopped = await stopIngressNginx(target.workspaceDir);
244
+ if (!stopped && isIngressRunning(target.workspaceDir)) {
245
+ console.error("Error: nginx ingress is still running; could not stop it.");
246
+ process.exit(1);
247
+ }
248
+ console.log(
249
+ stopped ? "nginx ingress stopped." : "nginx ingress is not running.",
250
+ );
251
+ }
252
+
253
+ async function status(target: NginxIngressTarget): Promise<void> {
254
+ const { workspaceDir, gatewayPort } = target;
255
+ const { confPath, logPath } = getIngressPaths(workspaceDir);
256
+ const pid = getIngressPid(workspaceDir);
257
+ if (pid === null) {
258
+ console.log("nginx ingress: not running");
259
+ return;
260
+ }
261
+ const { port } = resolveTunnelTargetPort(workspaceDir, gatewayPort);
262
+ console.log("nginx ingress: running");
263
+ console.log(` PID: ${pid}`);
264
+ console.log(` Listen: http://127.0.0.1:${port}`);
265
+ console.log(` Gateway: http://127.0.0.1:${gatewayPort}`);
266
+ console.log(` Config: ${confPath}`);
267
+ console.log(` Log: ${logPath}`);
268
+ }
269
+
270
+ export async function nginxIngress(): Promise<void> {
271
+ const args = process.argv.slice(3);
272
+ const sub = args[0];
273
+
274
+ if (!sub || sub === "--help" || sub === "-h") {
275
+ printHelp();
276
+ process.exit(sub ? 0 : 1);
277
+ }
278
+
279
+ // Joins all remaining positionals so unquoted multi-word display names
280
+ // resolve as one identifier (cli/AGENTS.md "Assistant targeting convention").
281
+ const assistantName = parseAssistantTargetArg(args.slice(1));
282
+ const target = resolveNginxIngressTarget(assistantName ?? null);
283
+
284
+ if (sub === "up") return up(target);
285
+ if (sub === "down") return down(target);
286
+ if (sub === "status") return status(target);
287
+
288
+ console.error(`Error: Unknown subcommand '${sub}'.`);
289
+ console.error("Run 'vellum nginx-ingress --help' for usage.");
290
+ process.exit(1);
291
+ }
@@ -2,7 +2,6 @@ import {
2
2
  findAssistantByName,
3
3
  getActiveAssistant,
4
4
  loadAllAssistants,
5
- normalizeVersion,
6
5
  resolveCloud,
7
6
  saveAssistantEntry,
8
7
  type AssistantEntry,
@@ -403,11 +402,6 @@ export async function rollback(): Promise<void> {
403
402
  networkName: res.network,
404
403
  },
405
404
  previousContainerInfo: entry.containerInfo,
406
- // Cleared (not preserved) when the rolled-back-to version is unknown
407
- version:
408
- previousVersion !== "unknown"
409
- ? normalizeVersion(previousVersion)
410
- : undefined,
411
405
  // Clear the backup path — it belonged to the upgrade we just rolled back
412
406
  preUpgradeBackupPath: undefined,
413
407
  previousDbMigrationVersion: undefined,
@@ -7,6 +7,7 @@ import {
7
7
  } from "../lib/assistant-config.js";
8
8
  import type { AssistantEntry } from "../lib/assistant-config.js";
9
9
  import { dockerResourceNames, sleepContainers } from "../lib/docker.js";
10
+ import { stopIngressNginx } from "../lib/nginx-ingress.js";
10
11
  import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
11
12
 
12
13
  const ACTIVE_CALL_LEASES_FILE = "active-call-leases.json";
@@ -134,9 +135,18 @@ export async function sleep(): Promise<void> {
134
135
  }
135
136
  }
136
137
 
138
+ // Stop assistant — use a generous timeout. On SIGTERM the daemon runs a
139
+ // WAL checkpoint before exiting, which can take several seconds on a
140
+ // multi-GB database. The default 2s grace in stopProcess() would SIGKILL a
141
+ // healthy daemon mid-checkpoint, forcing a costly multi-minute WAL recovery
142
+ // on the next start. The timeout is only a SIGKILL ceiling — stopProcess
143
+ // returns as soon as the process exits, so this adds no delay in the common
144
+ // case and only applies when the daemon is genuinely wedged.
137
145
  const assistantStopped = await stopProcessByPidFile(
138
146
  assistantPidFile,
139
147
  "assistant",
148
+ undefined,
149
+ 120_000,
140
150
  );
141
151
  if (!assistantStopped) {
142
152
  console.log("Assistant is not running.");
@@ -157,4 +167,11 @@ export async function sleep(): Promise<void> {
157
167
  } else {
158
168
  console.log("Gateway stopped.");
159
169
  }
170
+
171
+ // Stop the nginx ingress if one is fronting this gateway — otherwise it
172
+ // keeps running against a dead upstream and serves 502s.
173
+ const ingressStopped = await stopIngressNginx(join(vellumDir, "workspace"));
174
+ if (ingressStopped) {
175
+ console.log("nginx ingress stopped.");
176
+ }
160
177
  }
@@ -391,9 +391,8 @@ async function exportFromAssistant(
391
391
  // daemon, the CLI is updated separately).
392
392
  let sourceRuntimeVersion: string;
393
393
  try {
394
- const identity = await callRuntimeWithAuthRetry(
395
- entry,
396
- async (token) => localRuntimeIdentity(entry, token),
394
+ const identity = await callRuntimeWithAuthRetry(entry, async (token) =>
395
+ localRuntimeIdentity(entry, token),
397
396
  );
398
397
  sourceRuntimeVersion = identity.version;
399
398
  } catch (err) {
@@ -427,16 +426,13 @@ async function exportFromAssistant(
427
426
  let jobId: string;
428
427
  let accessToken: string;
429
428
  try {
430
- const result = await callRuntimeWithAuthRetry(
431
- entry,
432
- async (token) => {
433
- const r = await localRuntimeExportToGcs(entry, token, {
434
- uploadUrl,
435
- description: "teleport export",
436
- });
437
- return { jobId: r.jobId, token };
438
- },
439
- );
429
+ const result = await callRuntimeWithAuthRetry(entry, async (token) => {
430
+ const r = await localRuntimeExportToGcs(entry, token, {
431
+ uploadUrl,
432
+ description: "teleport export",
433
+ });
434
+ return { jobId: r.jobId, token };
435
+ });
440
436
  jobId = result.jobId;
441
437
  accessToken = result.token;
442
438
  } catch (err) {
@@ -734,9 +730,8 @@ async function importToAssistant(
734
730
  // target can't actually load) whenever the two drift apart.
735
731
  let targetRuntimeVersion: string;
736
732
  try {
737
- const identity = await callRuntimeWithAuthRetry(
738
- entry,
739
- (token) => localRuntimeIdentity(entry, token),
733
+ const identity = await callRuntimeWithAuthRetry(entry, (token) =>
734
+ localRuntimeIdentity(entry, token),
740
735
  );
741
736
  targetRuntimeVersion = identity.version;
742
737
  } catch (err) {
@@ -779,15 +774,12 @@ async function importToAssistant(
779
774
  let jobId: string;
780
775
  let accessToken: string;
781
776
  try {
782
- const result = await callRuntimeWithAuthRetry(
783
- entry,
784
- async (token) => {
785
- const r = await localRuntimeImportFromGcs(entry, token, {
786
- bundleUrl,
787
- });
788
- return { jobId: r.jobId, token };
789
- },
790
- );
777
+ const result = await callRuntimeWithAuthRetry(entry, async (token) => {
778
+ const r = await localRuntimeImportFromGcs(entry, token, {
779
+ bundleUrl,
780
+ });
781
+ return { jobId: r.jobId, token };
782
+ });
791
783
  jobId = result.jobId;
792
784
  accessToken = result.token;
793
785
  } catch (err) {
@@ -910,17 +902,12 @@ export async function resolveOrHatchTarget(
910
902
 
911
903
  if (targetEnv === "docker") {
912
904
  const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
913
- await hatchDocker(
914
- "vellum",
915
- false,
916
- targetName ?? null,
917
- false,
918
- {},
919
- {},
920
- {
921
- setupProviderCredentials: false,
922
- },
923
- );
905
+ await hatchDocker({
906
+ species: "vellum",
907
+ detached: false,
908
+ name: targetName ?? null,
909
+ setupProviderCredentials: false,
910
+ });
924
911
  const entry = targetName
925
912
  ? findAssistantByName(targetName)
926
913
  : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
@@ -1,7 +1,12 @@
1
1
  import { join } from "path";
2
2
 
3
- import { resolveAssistant } from "../lib/assistant-config";
3
+ import { resolveAssistant, type AssistantEntry } from "../lib/assistant-config";
4
4
  import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
5
+ import { GATEWAY_PORT } from "../lib/constants.js";
6
+ import {
7
+ isAssistantFeatureFlagEnabled,
8
+ WEB_REMOTE_INGRESS_FLAG,
9
+ } from "../lib/feature-flags.js";
5
10
  import { runNgrokTunnel } from "../lib/ngrok";
6
11
 
7
12
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -88,6 +93,46 @@ function parseArgs(): TunnelArgs {
88
93
  return { assistantName, provider };
89
94
  }
90
95
 
96
+ function parsePortFromUrl(url: unknown): number | undefined {
97
+ if (typeof url !== "string" || !url.trim()) return undefined;
98
+ try {
99
+ const port = Number(new URL(url).port);
100
+ return Number.isInteger(port) && port > 0 && port <= 65535
101
+ ? port
102
+ : undefined;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function resolveEntryGatewayPort(entry: AssistantEntry): number {
109
+ return (
110
+ entry.resources?.gatewayPort ??
111
+ parsePortFromUrl(entry.localUrl) ??
112
+ parsePortFromUrl(entry.runtimeUrl) ??
113
+ GATEWAY_PORT
114
+ );
115
+ }
116
+
117
+ async function shouldPreferNginxIngress(
118
+ assistantId: string,
119
+ gatewayPort: number,
120
+ ): Promise<boolean> {
121
+ try {
122
+ return await isAssistantFeatureFlagEnabled(
123
+ assistantId,
124
+ WEB_REMOTE_INGRESS_FLAG,
125
+ { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
126
+ );
127
+ } catch (err) {
128
+ throw new Error(
129
+ `Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag before starting the tunnel. Is the assistant running? Try \`vellum wake\` and retry. ${
130
+ err instanceof Error ? err.message : String(err)
131
+ }`,
132
+ );
133
+ }
134
+ }
135
+
91
136
  export async function tunnel(): Promise<void> {
92
137
  const { assistantName, provider } = parseArgs();
93
138
 
@@ -104,21 +149,34 @@ export async function tunnel(): Promise<void> {
104
149
  process.exit(1);
105
150
  }
106
151
 
152
+ const resources = entry.resources;
153
+ const gatewayPort = resolveEntryGatewayPort(entry);
154
+ const baseTunnelOpts = {
155
+ port: gatewayPort,
156
+ ...(resources
157
+ ? { workspaceDir: join(resources.instanceDir, ".vellum", "workspace") }
158
+ : {}),
159
+ };
160
+
107
161
  if (provider === "ngrok") {
108
- await runNgrokTunnel();
162
+ await runNgrokTunnel({
163
+ ...baseTunnelOpts,
164
+ preferNginxIngress: await shouldPreferNginxIngress(
165
+ entry.assistantId,
166
+ gatewayPort,
167
+ ),
168
+ });
109
169
  return;
110
170
  }
111
171
 
112
172
  if (provider === "cloudflare") {
113
- const resources = entry.resources;
114
- await runCloudflareTunnel(
115
- resources
116
- ? {
117
- port: resources.gatewayPort,
118
- workspaceDir: join(resources.instanceDir, ".vellum", "workspace"),
119
- }
120
- : {},
121
- );
173
+ await runCloudflareTunnel({
174
+ ...baseTunnelOpts,
175
+ preferNginxIngress: await shouldPreferNginxIngress(
176
+ entry.assistantId,
177
+ gatewayPort,
178
+ ),
179
+ });
122
180
  return;
123
181
  }
124
182
 
@@ -6,7 +6,6 @@ import {
6
6
  findAssistantByName,
7
7
  getActiveAssistant,
8
8
  loadAllAssistants,
9
- normalizeVersion,
10
9
  resolveCloud,
11
10
  saveAssistantEntry,
12
11
  type AssistantEntry,
@@ -496,7 +495,6 @@ async function upgradeDocker(
496
495
  previousContainerInfo: entry.containerInfo,
497
496
  previousDbMigrationVersion: preMigrationState.dbVersion,
498
497
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
499
- version: normalizeVersion(versionTag),
500
498
  // Preserve the backup path so `vellum rollback` can restore it later
501
499
  preUpgradeBackupPath: backupPath ?? undefined,
502
500
  };
@@ -9,7 +9,6 @@ import {
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
10
  import {
11
11
  leaseGuardianToken,
12
- loadGuardianToken,
13
12
  resetGuardianBootstrap,
14
13
  seedGuardianTokenFromSiblingEnv,
15
14
  } from "../lib/guardian-token.js";
@@ -43,7 +42,7 @@ export async function wake(): Promise<void> {
43
42
  " --foreground Run assistant in foreground with logs printed to terminal",
44
43
  );
45
44
  console.log(
46
- " --repair-guardian Re-provision the guardian token if missing (resets the\n" +
45
+ " --repair-guardian Force-re-provision the guardian token (resets the\n" +
47
46
  " gateway bootstrap and re-leases — REVOKES other device-bound\n" +
48
47
  " tokens, so only use deliberately, never from auto-repair)",
49
48
  );
@@ -238,8 +237,11 @@ export async function wake(): Promise<void> {
238
237
  console.log(" Seeded guardian token from sibling environment.");
239
238
  }
240
239
 
241
- // Last-resort recovery (explicit `--repair-guardian` only): if no guardian
242
- // token exists for this env even after sibling seeding, re-provision one. The
240
+ // Last-resort recovery (explicit `--repair-guardian` only): force a
241
+ // re-provision. Token health can't be judged locally a connect can 401
242
+ // off a token whose local expiry looks fine (revoked, mis-seeded, wrong
243
+ // principal) — and the user explicitly confirmed the destructive repair,
244
+ // so guessing "looks healthy, skip" just recreates the no-op loop. The
243
245
  // single-use bootstrap secret may already be spent — a prior connect can
244
246
  // lease a token that's then lost, or the gateway marks the secret consumed
245
247
  // before the client persists it — which otherwise bricks connect into a
@@ -248,7 +250,7 @@ export async function wake(): Promise<void> {
248
250
  // by the lockfile secret — mirrors the macOS client's forceReBootstrap), then
249
251
  // re-lease. Gated behind the flag because the re-lease revokes other
250
252
  // device-bound tokens; it must never run from the automatic repair path.
251
- if (repairGuardian && !loadGuardianToken(entry.assistantId)) {
253
+ if (repairGuardian) {
252
254
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
253
255
  const maxAttempts = 3;
254
256
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {