@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
package/src/lib/ngrok.ts CHANGED
@@ -6,11 +6,12 @@ import { GATEWAY_PORT } from "./constants.js";
6
6
  import {
7
7
  clearIngressUrl,
8
8
  getDefaultWorkspaceDir,
9
+ loadNgrokDomain,
9
10
  loadRawConfig,
10
11
  saveIngressUrl,
12
+ saveNgrokDomain,
11
13
  } from "./ingress-config.js";
12
14
  import { loopbackSafeFetch } from "./loopback-fetch.js";
13
- import { resolveTunnelTargetPort } from "./nginx-ingress.js";
14
15
 
15
16
  const NGROK_API_URL = "http://127.0.0.1:4040/api/tunnels";
16
17
  const NGROK_POLL_INTERVAL_MS = 500;
@@ -59,40 +60,59 @@ async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
59
60
  }
60
61
  }
61
62
 
62
- /**
63
- * Find an existing ngrok tunnel that targets the given local address.
64
- * Returns the HTTPS public URL if found, null otherwise.
65
- */
66
- export async function findExistingTunnel(
67
- targetPort: number,
68
- ): Promise<string | null> {
69
- const tunnels = await queryNgrokTunnels();
70
- if (!tunnels || tunnels.length === 0) return null;
71
-
63
+ /** Whether a tunnel targets the given local port, under any addr spelling. */
64
+ function tunnelTargetsPort(tunnel: NgrokTunnel, targetPort: number): boolean {
72
65
  const targetAddrs = [
73
66
  `localhost:${targetPort}`,
74
67
  `127.0.0.1:${targetPort}`,
75
68
  `http://localhost:${targetPort}`,
76
69
  `http://127.0.0.1:${targetPort}`,
77
70
  ];
71
+ return targetAddrs.includes(tunnel.config?.addr ?? "");
72
+ }
78
73
 
79
- // Prefer HTTPS tunnel
80
- for (const t of tunnels) {
81
- const addr = t.config?.addr ?? "";
82
- if (targetAddrs.includes(addr) && t.public_url.startsWith("https://")) {
83
- return t.public_url;
84
- }
85
- }
74
+ /** Pick the tunnel for the target port (and domain, when set), HTTPS first. */
75
+ function pickMatchingTunnel(
76
+ tunnels: NgrokTunnel[],
77
+ targetPort: number,
78
+ domain?: string,
79
+ ): string | null {
80
+ const matches = tunnels.filter(
81
+ (t) =>
82
+ tunnelTargetsPort(t, targetPort) &&
83
+ (!domain || urlMatchesDomain(t.public_url, domain)),
84
+ );
85
+ const httpsTunnel = matches.find((t) => t.public_url.startsWith("https://"));
86
+ if (httpsTunnel) return httpsTunnel.public_url;
87
+ return matches.find((t) => t.public_url)?.public_url ?? null;
88
+ }
86
89
 
87
- // Fall back to any tunnel pointing at the target
88
- for (const t of tunnels) {
89
- const addr = t.config?.addr ?? "";
90
- if (targetAddrs.includes(addr) && t.public_url) {
91
- return t.public_url;
92
- }
93
- }
90
+ /** Render listed tunnels as `url -> addr` pairs for mismatch diagnostics. */
91
+ function describeTunnels(tunnels: NgrokTunnel[]): string {
92
+ return tunnels
93
+ .map((t) => `${t.public_url} -> ${t.config?.addr ?? "unknown target"}`)
94
+ .join(", ");
95
+ }
96
+
97
+ /** Diagnostic for a running ngrok agent whose tunnels all miss the target port. */
98
+ function staleAgentDiagnostic(tunnels: NgrokTunnel[], port: number): string {
99
+ return `an ngrok agent is already running but tunnels a different local port (${describeTunnels(tunnels)}), not ${port}. It was likely started before the tunnel edge unification or by an external process.`;
100
+ }
94
101
 
95
- return null;
102
+ /** Recovery copy for a saved domain whose reservation may have lapsed. */
103
+ function savedDomainRecoveryHint(domain: string): string {
104
+ return `The saved ngrok domain '${domain}' (ingress.ngrok.domain in the workspace config) may no longer be reserved. Run \`vellum tunnel --provider ngrok --clear-domain\` to drop it and tunnel without one.`;
105
+ }
106
+
107
+ /** Whether a tunnel public URL's host equals the given reserved domain. */
108
+ function urlMatchesDomain(publicUrl: string, domain: string): boolean {
109
+ try {
110
+ return (
111
+ new URL(publicUrl).hostname.toLowerCase() === domain.trim().toLowerCase()
112
+ );
113
+ } catch {
114
+ return false;
115
+ }
96
116
  }
97
117
 
98
118
  /**
@@ -103,11 +123,15 @@ export async function findExistingTunnel(
103
123
  * parent process — which would either prevent the CLI from exiting (if
104
124
  * handles are left open) or send SIGPIPE to ngrok (if destroyed).
105
125
  *
126
+ * When `domain` is set, the tunnel binds that reserved ngrok domain via
127
+ * `--domain=<domain>`.
128
+ *
106
129
  * Returns the spawned child process.
107
130
  */
108
131
  export function startNgrokProcess(
109
132
  targetPort: number,
110
133
  logFilePath?: string,
134
+ domain?: string,
111
135
  ): ChildProcess {
112
136
  let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
113
137
  let fd: number | undefined;
@@ -118,7 +142,14 @@ export function startNgrokProcess(
118
142
  stdio = ["ignore", fd, fd];
119
143
  }
120
144
 
121
- const child = spawn("ngrok", ["http", String(targetPort), "--log=stdout"], {
145
+ // Explicit over a bare port, which ngrok expands to `localhost`, whose ::1
146
+ // answer resolves first on macOS and races the edge's IPv4 bind.
147
+ const args = ["http", `127.0.0.1:${targetPort}`, "--log=stdout"];
148
+ if (domain) {
149
+ args.push(`--domain=${domain}`);
150
+ }
151
+
152
+ const child = spawn("ngrok", args, {
122
153
  detached: true,
123
154
  stdio,
124
155
  });
@@ -134,22 +165,21 @@ export function startNgrokProcess(
134
165
  }
135
166
 
136
167
  /**
137
- * Poll the ngrok local API until an HTTPS tunnel URL appears.
168
+ * Poll the ngrok local API until a tunnel appears for the target port (and
169
+ * reserved domain, when one is requested), preferring HTTPS.
138
170
  * Returns the public URL, or throws if the timeout is exceeded.
139
171
  */
140
172
  export async function waitForNgrokUrl(
173
+ targetPort: number,
174
+ domain?: string,
141
175
  timeoutMs: number = NGROK_POLL_TIMEOUT_MS,
142
176
  ): Promise<string> {
143
177
  const start = Date.now();
144
178
  while (Date.now() - start < timeoutMs) {
145
179
  const tunnels = await queryNgrokTunnels();
146
180
  if (tunnels && tunnels.length > 0) {
147
- // Prefer HTTPS
148
- const httpsTunnel = tunnels.find((t) =>
149
- t.public_url.startsWith("https://"),
150
- );
151
- if (httpsTunnel) return httpsTunnel.public_url;
152
- if (tunnels[0]?.public_url) return tunnels[0].public_url;
181
+ const url = pickMatchingTunnel(tunnels, targetPort, domain);
182
+ if (url) return url;
153
183
  }
154
184
  await new Promise((r) => setTimeout(r, NGROK_POLL_INTERVAL_MS));
155
185
  }
@@ -158,18 +188,27 @@ export async function waitForNgrokUrl(
158
188
  );
159
189
  }
160
190
 
191
+ /**
192
+ * Check whether an already-loaded workspace config has webhook-based
193
+ * integrations (e.g. Telegram, Twilio) that require a public ingress URL.
194
+ */
195
+ export function hasWebhookIntegrations(
196
+ config: Record<string, unknown>,
197
+ ): boolean {
198
+ const telegram = config.telegram as Record<string, unknown> | undefined;
199
+ if (telegram?.botUsername) return true;
200
+ const twilio = config.twilio as Record<string, unknown> | undefined;
201
+ if (twilio?.accountSid || twilio?.phoneNumber) return true;
202
+ return false;
203
+ }
204
+
161
205
  /**
162
206
  * Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
163
207
  * configured that require a public ingress URL.
164
208
  */
165
209
  function hasWebhookIntegrationsConfigured(workspaceDir: string): boolean {
166
210
  try {
167
- const config = loadRawConfig(workspaceDir);
168
- const telegram = config.telegram as Record<string, unknown> | undefined;
169
- if (telegram?.botUsername) return true;
170
- const twilio = config.twilio as Record<string, unknown> | undefined;
171
- if (twilio?.accountSid || twilio?.phoneNumber) return true;
172
- return false;
211
+ return hasWebhookIntegrations(loadRawConfig(workspaceDir));
173
212
  } catch {
174
213
  return false;
175
214
  }
@@ -215,13 +254,34 @@ export async function maybeStartNgrokTunnel(
215
254
  const version = getNgrokVersion();
216
255
  if (!version) return null;
217
256
 
257
+ const savedDomain = loadNgrokDomain(workspaceDir) ?? undefined;
258
+
218
259
  // Reuse an existing tunnel if one is already running
219
- const existingUrl = await findExistingTunnel(targetPort);
260
+ const runningTunnels = (await queryNgrokTunnels()) ?? [];
261
+ const existingUrl = pickMatchingTunnel(runningTunnels, targetPort);
220
262
  if (existingUrl) {
263
+ if (savedDomain && !urlMatchesDomain(existingUrl, savedDomain)) {
264
+ // Spawning a second agent would collide (ERR_NGROK_334), and saving the
265
+ // mismatched URL would clobber the reserved-domain intent. Leave the
266
+ // tunnel running but refuse to bless it in config.
267
+ console.warn(
268
+ ` ⚠ Existing ngrok tunnel ${existingUrl} does not match the reserved domain '${savedDomain}'. Ignoring it. Stop the running ngrok agent and run \`vellum tunnel --provider ngrok --domain ${savedDomain}\` to bind the reserved domain.`,
269
+ );
270
+ return null;
271
+ }
221
272
  console.log(` Found existing ngrok tunnel: ${existingUrl}`);
222
273
  saveIngressUrl(workspaceDir, existingUrl);
223
274
  return null;
224
275
  }
276
+ if (runningTunnels.length > 0) {
277
+ // An agent is up but tunnels some other local port (likely started before
278
+ // the edge unification, or by an external process). Spawning a second
279
+ // agent would collide (ERR_NGROK_334) on single-agent plans, so skip.
280
+ console.warn(
281
+ ` ⚠ ${staleAgentDiagnostic(runningTunnels, targetPort)} Stop that ngrok agent, then run \`vellum tunnel --provider ngrok\` to tunnel the local edge.`,
282
+ );
283
+ return null;
284
+ }
225
285
 
226
286
  console.log(` Starting ngrok tunnel for webhook integrations...`);
227
287
 
@@ -232,33 +292,39 @@ export async function maybeStartNgrokTunnel(
232
292
  // Writing to a log file sidesteps both issues — the file descriptor is
233
293
  // inherited by the detached ngrok process and remains valid after CLI exit.
234
294
  const ngrokLogPath = join(workspaceDir, "data", "logs", "ngrok.log");
235
- const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
295
+ const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath, savedDomain);
236
296
  ngrokProcess.unref();
237
297
 
238
298
  try {
239
- const publicUrl = await waitForNgrokUrl();
299
+ const publicUrl = await waitForNgrokUrl(targetPort, savedDomain);
240
300
  saveIngressUrl(workspaceDir, publicUrl);
241
301
  console.log(` Tunnel established: ${publicUrl}`);
242
302
 
243
303
  return ngrokProcess;
244
304
  } catch {
245
305
  console.warn(
246
- ` ⚠ Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel\`.`,
306
+ ` ⚠ Could not start ngrok tunnel. Webhook integrations may not work until you run \`vellum tunnel --provider ngrok\`.`,
247
307
  );
308
+ if (savedDomain) {
309
+ console.warn(` ⚠ ${savedDomainRecoveryHint(savedDomain)}`);
310
+ }
248
311
  if (!ngrokProcess.killed) ngrokProcess.kill("SIGTERM");
249
312
  return null;
250
313
  }
251
314
  }
252
315
 
253
316
  export interface RunNgrokTunnelOptions {
254
- /** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
317
+ /** Local edge port to forward. Defaults to the global GATEWAY_PORT. */
255
318
  port?: number;
256
319
  /** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
257
320
  workspaceDir?: string;
258
- /** Prefer nginx ingress over the gateway port when it is running. */
259
- preferNginxIngress?: boolean;
260
321
  /** Lockfile entry to mirror the ingress URL onto (`ingressUrl`). */
261
322
  assistantId?: string;
323
+ /**
324
+ * Reserved ngrok domain to bind. Persisted so wake restores reuse it.
325
+ * When omitted, a previously saved domain is reused without being rewritten.
326
+ */
327
+ domain?: string;
262
328
  }
263
329
 
264
330
  /**
@@ -286,23 +352,42 @@ export async function runNgrokTunnel(
286
352
  console.log(`Using ${version}`);
287
353
 
288
354
  const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
289
- const gatewayPort = opts.port ?? GATEWAY_PORT;
290
- const { port, viaIngress } = resolveTunnelTargetPort(
291
- workspaceDir,
292
- gatewayPort,
293
- { preferNginxIngress: opts.preferNginxIngress === true },
294
- );
295
- if (viaIngress) {
296
- console.log(
297
- `nginx ingress detected — tunneling to it on 127.0.0.1:${port}.`,
298
- );
355
+ const port = opts.port ?? GATEWAY_PORT;
356
+
357
+ // The saved domain is standing intent: a run without --domain reuses it,
358
+ // and only an explicit --domain rewrites it.
359
+ const domain = opts.domain ?? loadNgrokDomain(workspaceDir) ?? undefined;
360
+ if (domain && !opts.domain) {
361
+ console.log(`Using saved ngrok domain: ${domain}`);
299
362
  }
300
363
 
301
- // Check for an existing ngrok tunnel pointing at the gateway
302
- const existingUrl = await findExistingTunnel(port);
364
+ // Check for an existing ngrok tunnel pointing at the local edge
365
+ const runningTunnels = (await queryNgrokTunnels()) ?? [];
366
+ const existingUrl = pickMatchingTunnel(runningTunnels, port);
367
+ if (!existingUrl && runningTunnels.length > 0) {
368
+ // Spawning a second agent would collide (ERR_NGROK_334) on single-agent
369
+ // plans; fail loudly instead, matching the domain-mismatch path.
370
+ console.error(`Error: ${staleAgentDiagnostic(runningTunnels, port)}`);
371
+ console.error(
372
+ "Stop the existing ngrok agent first, then re-run this command to tunnel the local edge.",
373
+ );
374
+ process.exit(1);
375
+ }
303
376
  if (existingUrl) {
377
+ if (domain && !urlMatchesDomain(existingUrl, domain)) {
378
+ console.error(
379
+ `Error: an ngrok tunnel is already running on port ${port} at ${existingUrl}, which does not match the ${opts.domain ? "requested" : "saved"} domain '${domain}'.`,
380
+ );
381
+ console.error(
382
+ "Stop the existing ngrok agent first, then re-run this command to bind the reserved domain.",
383
+ );
384
+ process.exit(1);
385
+ }
304
386
  console.log(`Found existing ngrok tunnel: ${existingUrl}`);
305
387
  saveIngressUrl(workspaceDir, existingUrl, opts.assistantId);
388
+ if (opts.domain) {
389
+ saveNgrokDomain(workspaceDir, opts.domain);
390
+ }
306
391
  console.log("Ingress URL saved to config.");
307
392
  console.log("");
308
393
  console.log(
@@ -321,7 +406,7 @@ export async function runNgrokTunnel(
321
406
 
322
407
  let publicUrl: string | undefined;
323
408
 
324
- const ngrokProcess = startNgrokProcess(port);
409
+ const ngrokProcess = startNgrokProcess(port, undefined, domain);
325
410
 
326
411
  const cleanup = () => {
327
412
  if (!ngrokProcess.killed) {
@@ -368,9 +453,12 @@ export async function runNgrokTunnel(
368
453
  });
369
454
 
370
455
  try {
371
- publicUrl = await waitForNgrokUrl();
456
+ publicUrl = await waitForNgrokUrl(port, domain);
372
457
  } catch (err) {
373
458
  cleanup();
459
+ if (domain && !opts.domain) {
460
+ console.error(savedDomainRecoveryHint(domain));
461
+ }
374
462
  throw err;
375
463
  }
376
464
 
@@ -378,7 +466,12 @@ export async function runNgrokTunnel(
378
466
  console.log(`Tunnel established: ${publicUrl}`);
379
467
  console.log(`Forwarding to: localhost:${port}`);
380
468
 
469
+ // The domain is standing intent, not tunnel state: cleanup clears the
470
+ // ingress URL but leaves the domain saved for wake/daemon restores.
381
471
  saveIngressUrl(workspaceDir, publicUrl, opts.assistantId);
472
+ if (opts.domain) {
473
+ saveNgrokDomain(workspaceDir, opts.domain);
474
+ }
382
475
  console.log("Ingress URL saved to config.");
383
476
  console.log("");
384
477
  console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
@@ -0,0 +1,167 @@
1
+ import { afterAll, describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ classifyProcess,
5
+ detectOrphanedProcesses,
6
+ isInteractiveCliSession,
7
+ } from "./orphan-detection.js";
8
+
9
+ // `mock.restore()` does not undo `mock.module()`; keep the real module so it
10
+ // can be restored in afterAll for later test files in the same run.
11
+ const realStepRunner = { ...(await import("./step-runner")) };
12
+
13
+ describe("isInteractiveCliSession", () => {
14
+ test("matches a live tunnel session launched via a script path", () => {
15
+ expect(
16
+ isInteractiveCliSession(
17
+ "bun /Users/x/.nvm/versions/node/v22.14.0/bin/vellum tunnel --provider ngrok",
18
+ ),
19
+ ).toBe(true);
20
+ });
21
+
22
+ test("matches interactive subcommands like logs and terminal", () => {
23
+ expect(isInteractiveCliSession("vellum logs foo")).toBe(true);
24
+ expect(isInteractiveCliSession("vellum terminal")).toBe(true);
25
+ });
26
+
27
+ test("matches the other long-running interactive subcommands", () => {
28
+ expect(isInteractiveCliSession("vellum events")).toBe(true);
29
+ expect(isInteractiveCliSession("vellum client")).toBe(true);
30
+ expect(isInteractiveCliSession("vellum ssh quiet-finch")).toBe(true);
31
+ expect(isInteractiveCliSession("vellum message quiet-finch hi")).toBe(true);
32
+ expect(isInteractiveCliSession("vellum workflows")).toBe(true);
33
+ expect(isInteractiveCliSession("vellum-cli tunnel --provider ngrok")).toBe(
34
+ true,
35
+ );
36
+ });
37
+
38
+ test("allows the known global flags before the subcommand", () => {
39
+ expect(isInteractiveCliSession("vellum --plain tunnel")).toBe(true);
40
+ expect(isInteractiveCliSession("vellum --no-color logs foo")).toBe(true);
41
+ expect(isInteractiveCliSession("vellum --no-color --plain events")).toBe(
42
+ true,
43
+ );
44
+ });
45
+
46
+ test("matches exec sessions even when argv mentions a service name", () => {
47
+ expect(
48
+ isInteractiveCliSession(
49
+ "vellum exec -it --service vellum-gateway -- /bin/sh",
50
+ ),
51
+ ).toBe(true);
52
+ });
53
+
54
+ test("does not match non-interactive CLI wrappers", () => {
55
+ expect(isInteractiveCliSession("vellum hatch")).toBe(false);
56
+ expect(isInteractiveCliSession("/usr/bin/vellum sleep")).toBe(false);
57
+ expect(isInteractiveCliSession("vellum wake")).toBe(false);
58
+ expect(isInteractiveCliSession("vellum wake --watch")).toBe(false);
59
+ });
60
+
61
+ test("matches the implicit TUI client (bare vellum)", () => {
62
+ expect(isInteractiveCliSession("vellum")).toBe(true);
63
+ expect(isInteractiveCliSession("bun /Users/x/.nvm/bin/vellum")).toBe(true);
64
+ expect(isInteractiveCliSession("vellum --plain")).toBe(true);
65
+ });
66
+
67
+ test("matches a foreground wake session", () => {
68
+ expect(isInteractiveCliSession("vellum wake --foreground")).toBe(true);
69
+ expect(isInteractiveCliSession("vellum wake --watch --foreground")).toBe(
70
+ true,
71
+ );
72
+ expect(
73
+ isInteractiveCliSession("bun /Users/x/bin/vellum wake --foreground"),
74
+ ).toBe(true);
75
+ });
76
+
77
+ test("interactive names in later argv tokens do not match", () => {
78
+ expect(isInteractiveCliSession("vellum hatch --name logs")).toBe(false);
79
+ expect(isInteractiveCliSession("vellum sleep logs")).toBe(false);
80
+ });
81
+
82
+ test("unknown flags before the subcommand do not match", () => {
83
+ expect(isInteractiveCliSession("vellum --verbose tunnel")).toBe(false);
84
+ });
85
+
86
+ test("repo paths containing vellum do not match", () => {
87
+ expect(
88
+ isInteractiveCliSession(
89
+ "node /Users/runner/work/vellum-assistant/vellum-assistant/scripts/build.js",
90
+ ),
91
+ ).toBe(false);
92
+ });
93
+ });
94
+
95
+ describe("classifyProcess", () => {
96
+ test("labels interactive CLI sessions as vellum like any other wrapper", () => {
97
+ expect(classifyProcess("vellum tunnel --provider ngrok")).toBe("vellum");
98
+ expect(classifyProcess("vellum logs foo")).toBe("vellum");
99
+ expect(classifyProcess("vellum-cli tunnel --provider ngrok")).toBe(
100
+ "vellum",
101
+ );
102
+ });
103
+
104
+ test("classifies non-interactive CLI wrappers as vellum", () => {
105
+ expect(classifyProcess("vellum hatch")).toBe("vellum");
106
+ expect(classifyProcess("vellum")).toBe("vellum");
107
+ expect(classifyProcess("/usr/bin/vellum sleep")).toBe("vellum");
108
+ expect(classifyProcess("vellum hatch --name logs")).toBe("vellum");
109
+ expect(classifyProcess("vellum sleep logs")).toBe("vellum");
110
+ });
111
+
112
+ test("service process classifications are unchanged", () => {
113
+ expect(classifyProcess("/opt/homebrew/bin/qdrant --config foo")).toBe(
114
+ "qdrant",
115
+ );
116
+ expect(classifyProcess("bun /x/bin/vellum-gateway --port 7830")).toBe(
117
+ "gateway",
118
+ );
119
+ expect(classifyProcess("bun /x/bin/vellum-daemon start")).toBe("assistant");
120
+ expect(classifyProcess("node daemon start")).toBe("assistant");
121
+ expect(
122
+ classifyProcess("bun /x/bin/vellum-openclaw-adapter --port 9000"),
123
+ ).toBe("openclaw-adapter");
124
+ });
125
+
126
+ test("macOS desktop app processes stay excluded", () => {
127
+ expect(
128
+ classifyProcess("/Applications/Vellum.app/Contents/MacOS/Vellum"),
129
+ ).toBe("unknown");
130
+ });
131
+
132
+ test("repo paths containing vellum do not match", () => {
133
+ expect(
134
+ classifyProcess(
135
+ "node /Users/runner/work/vellum-assistant/vellum-assistant/scripts/build.js",
136
+ ),
137
+ ).toBe("unknown");
138
+ });
139
+ });
140
+
141
+ describe("detectOrphanedProcesses", () => {
142
+ afterAll(() => {
143
+ mock.module("./step-runner", () => realStepRunner);
144
+ });
145
+
146
+ test("skips live interactive sessions but still reports orphaned services", async () => {
147
+ const psOutput = [
148
+ "101 1 bun /Users/x/bin/vellum tunnel --provider ngrok",
149
+ "102 1 vellum exec -it --service vellum-gateway -- /bin/sh",
150
+ "103 1 vellum --plain logs foo",
151
+ "104 1 bun /x/bin/vellum-gateway --port 7830",
152
+ "105 1 vellum hatch",
153
+ ].join("\n");
154
+ mock.module("./step-runner", () => ({
155
+ ...realStepRunner,
156
+ execOutput: async () => psOutput,
157
+ }));
158
+
159
+ const orphans = await detectOrphanedProcesses({
160
+ excludePids: new Set<string>(),
161
+ });
162
+
163
+ expect(orphans.map((o) => o.pid).sort()).toEqual(["104", "105"]);
164
+ expect(orphans.find((o) => o.pid === "104")?.name).toBe("gateway");
165
+ expect(orphans.find((o) => o.pid === "105")?.name).toBe("vellum");
166
+ });
167
+ });
@@ -37,6 +37,41 @@ export function classifyProcess(command: string): string {
37
37
  return "unknown";
38
38
  }
39
39
 
40
+ /**
41
+ * True when the command line is a deliberate long-running interactive CLI
42
+ * session (e.g. a live `vellum tunnel` in someone's terminal). Such sessions
43
+ * have no PID-file registration, so `vellum clean` would otherwise kill them
44
+ * mid-session. The command line may be "bun /path/to/bin/vellum tunnel ...",
45
+ * so match the subcommand right after the vellum binary/script path (allowing
46
+ * only the known global flags in between; see GLOBAL_FLAGS in cli/src/index.ts).
47
+ * Later argv tokens (e.g. "vellum hatch --name logs") must not match.
48
+ *
49
+ * `detectOrphanedProcesses` consults this before classification so that
50
+ * service-like substrings in later argv (e.g. `vellum exec -it --service
51
+ * vellum-gateway -- /bin/sh`) cannot re-flag a live session, while
52
+ * `classifyProcess` stays a pure display label for `vellum ps`.
53
+ *
54
+ * Also spared: bare `vellum` (no subcommand launches the implicit TUI client
55
+ * via tryLaunchClient) and `vellum wake --foreground` (stays attached with
56
+ * logs in the terminal).
57
+ */
58
+ export function isInteractiveCliSession(command: string): boolean {
59
+ const vellumToken = /(?:^|\/)vellum(?:-cli)?(?:\s+--(?:no-color|plain))*/;
60
+ const interactiveSubcommand = new RegExp(
61
+ vellumToken.source +
62
+ String.raw`\s+(?:tunnel|events|logs|client|terminal|ssh|exec|message|workflows)\b`,
63
+ );
64
+ const implicitTuiClient = new RegExp(vellumToken.source + String.raw`\s*$`);
65
+ const foregroundWake = new RegExp(
66
+ vellumToken.source + String.raw`\s+wake\b(?:\s+\S+)*\s--foreground\b`,
67
+ );
68
+ return (
69
+ interactiveSubcommand.test(command) ||
70
+ implicitTuiClient.test(command) ||
71
+ foregroundWake.test(command)
72
+ );
73
+ }
74
+
40
75
  export function parseRemotePs(output: string): RemoteProcess[] {
41
76
  return output
42
77
  .trim()
@@ -152,6 +187,11 @@ export async function detectOrphanedProcesses(
152
187
  for (const p of procs) {
153
188
  if (p.pid === ownPid || seenPids.has(p.pid)) continue;
154
189
  if (knownPids.has(p.pid)) continue;
190
+ // Live interactive sessions are spared before classification so that
191
+ // service substrings in their argv cannot mark them as orphans.
192
+ if (isInteractiveCliSession(p.command)) {
193
+ continue;
194
+ }
155
195
  const type = classifyProcess(p.command);
156
196
  if (type === "unknown") continue;
157
197
  results.push({ name: type, pid: p.pid, source: "process table" });
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { REMOTE_WEB_PAIRING_CODE_TTL_MS } from "@vellumai/service-contracts/remote-web-pairing";
4
+
5
+ import { formatWebApproveFailure, parseGatewayErrorCode } from "./pair.js";
6
+
7
+ const GATEWAY_URL = "http://127.0.0.1:20100";
8
+ // Callers pass formatAssistantReference() output: display name plus stable ID.
9
+ const ASSISTANT_REFERENCE = "example-assistant (asst_0123456789abcdef)";
10
+ const ENV_NAME = "local";
11
+ const TTL_NOTE = `expire after ${Math.round(REMOTE_WEB_PAIRING_CODE_TTL_MS / 60_000)} minutes`;
12
+
13
+ describe("formatWebApproveFailure", () => {
14
+ test("INVALID_USER_CODE names the gateway, assistant reference, and environment", () => {
15
+ const message = formatWebApproveFailure(
16
+ GATEWAY_URL,
17
+ ASSISTANT_REFERENCE,
18
+ ENV_NAME,
19
+ "INVALID_USER_CODE",
20
+ );
21
+ expect(message).not.toBeNull();
22
+ expect(message).toContain(`No such pairing code on ${GATEWAY_URL}`);
23
+ expect(message).toContain(`assistant "${ASSISTANT_REFERENCE}"`);
24
+ expect(message).toContain("asst_0123456789abcdef");
25
+ expect(message).toContain(`environment "${ENV_NAME}"`);
26
+ });
27
+
28
+ test("INVALID_USER_CODE includes the TTL note and the cross-environment hint", () => {
29
+ const message = formatWebApproveFailure(
30
+ GATEWAY_URL,
31
+ ASSISTANT_REFERENCE,
32
+ ENV_NAME,
33
+ "INVALID_USER_CODE",
34
+ );
35
+ expect(message).toContain(TTL_NOTE);
36
+ expect(message).toContain("single-use");
37
+ expect(message).toContain("different assistant or environment");
38
+ expect(message).toContain("VELLUM_ENVIRONMENT");
39
+ });
40
+
41
+ test("EXPIRED_USER_CODE gets the same diagnostic with an expiry lead line", () => {
42
+ const message = formatWebApproveFailure(
43
+ GATEWAY_URL,
44
+ ASSISTANT_REFERENCE,
45
+ ENV_NAME,
46
+ "EXPIRED_USER_CODE",
47
+ );
48
+ expect(message).toContain(`Pairing code expired on ${GATEWAY_URL}`);
49
+ expect(message).toContain(TTL_NOTE);
50
+ expect(message).toContain("VELLUM_ENVIRONMENT");
51
+ });
52
+
53
+ test("unknown error codes return null so callers fall back to the generic HTTP error", () => {
54
+ for (const code of ["RATE_LIMITED", "BAD_REQUEST", "", null]) {
55
+ expect(
56
+ formatWebApproveFailure(
57
+ GATEWAY_URL,
58
+ ASSISTANT_REFERENCE,
59
+ ENV_NAME,
60
+ code,
61
+ ),
62
+ ).toBeNull();
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("parseGatewayErrorCode", () => {
68
+ test("extracts the code from a gateway error envelope", () => {
69
+ const body = JSON.stringify({
70
+ error: { code: "INVALID_USER_CODE", message: "invalid pairing code" },
71
+ });
72
+ expect(parseGatewayErrorCode(body)).toBe("INVALID_USER_CODE");
73
+ });
74
+
75
+ test("returns null for non-JSON bodies", () => {
76
+ expect(parseGatewayErrorCode("Not Found")).toBeNull();
77
+ expect(parseGatewayErrorCode("")).toBeNull();
78
+ });
79
+
80
+ test("returns null when the envelope carries no string code", () => {
81
+ expect(parseGatewayErrorCode(JSON.stringify({ error: {} }))).toBeNull();
82
+ expect(
83
+ parseGatewayErrorCode(JSON.stringify({ error: { code: 404 } })),
84
+ ).toBeNull();
85
+ expect(parseGatewayErrorCode(JSON.stringify({}))).toBeNull();
86
+ });
87
+ });