@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
@@ -31,6 +31,7 @@ import { extractFlag } from "../lib/arg-utils.js";
31
31
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
32
32
  import {
33
33
  formatAssistantLookupError,
34
+ formatAssistantReference,
34
35
  lookupAssistantByIdentifier,
35
36
  resolveAssistant,
36
37
  type AssistantEntry,
@@ -40,6 +41,7 @@ import {
40
41
  getClientRegistrationHeaders,
41
42
  } from "../lib/client-identity.js";
42
43
  import { GATEWAY_PORT } from "../lib/constants.js";
44
+ import { getCurrentEnvironment } from "../lib/environments/resolve.js";
43
45
  import {
44
46
  formatFeatureFlagGateMessage,
45
47
  isAssistantFeatureFlagEnabled,
@@ -47,6 +49,7 @@ import {
47
49
  } from "../lib/feature-flags.js";
48
50
  import { getLocalLanIPv4 } from "../lib/local.js";
49
51
  import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
52
+ import { formatWebApproveFailure, parseGatewayErrorCode } from "../lib/pair.js";
50
53
  import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
51
54
 
52
55
  function assistantDisplayName(entry: AssistantEntry): string {
@@ -89,7 +92,8 @@ ARGUMENTS:
89
92
  OPTIONS:
90
93
  --url <url> Reachable gateway URL to advertise in the bundle
91
94
  (default: the assistant's runtime URL, not loopback)
92
- --label <name> Human label for this pairing (echoed in the output)
95
+ --label <name> Human label for this pairing (echoed in the output; with
96
+ --qr --app it also names the assistant in the connect link)
93
97
  --web Create a browser pairing URL for remote web access
94
98
  --web-approve <code>
95
99
  Approve a browser pairing code shown by /assistant/pair
@@ -100,8 +104,10 @@ OPTIONS:
100
104
  loopback or non-https URLs.
101
105
  --app With --qr: encode the QR as a vellum-assistant://connect
102
106
  link that opens the Vellum iOS app directly (the plain
103
- https pairing URL is printed as a fallback). Requires an
104
- app build with the connect handler installed on the phone.
107
+ https pairing URL is printed as a fallback). The link
108
+ carries the assistant's name (--label overrides it) so the
109
+ app can label the pairing. Requires an app build with the
110
+ connect handler installed on the phone.
105
111
  --app-scheme <scheme>
106
112
  URL scheme for --app links (default: vellum-assistant;
107
113
  dev/staging app builds use vellum-assistant-dev /
@@ -138,33 +144,40 @@ const DEFAULT_APP_CONNECT_SCHEME = "vellum-assistant";
138
144
 
139
145
  /**
140
146
  * Compose the custom-scheme link the iOS app's connect handler accepts:
141
- * `<scheme>://connect?url=<base>&code=<device code>`. The app persists the
142
- * base as its self-hosted server and opens the pair page with the code.
147
+ * `<scheme>://connect?url=<base>&code=<device code>[&name=<label>]`. The app
148
+ * persists the base (and the label, when present) as its self-hosted server
149
+ * and opens the pair page with the code.
143
150
  */
144
151
  export function buildAppConnectUrl(
145
152
  scheme: string,
146
153
  baseUrl: string,
147
154
  deviceCode: string,
155
+ name?: string,
148
156
  ): string {
149
157
  const params = new URLSearchParams({ url: baseUrl, code: deviceCode });
150
- return `${scheme}://connect?${params.toString()}`;
158
+ if (name) {
159
+ params.set("name", name);
160
+ }
161
+ // Percent-encode spaces: URLSearchParams form-encodes them as `+`, which
162
+ // the app's Foundation URLComponents parser keeps as a literal plus.
163
+ const query = params.toString().replace(/\+/g, "%20");
164
+ return `${scheme}://connect?${query}`;
151
165
  }
152
166
 
153
167
  /**
154
168
  * POST a JSON body to a loopback gateway route, exiting with a clear message
155
- * when the gateway is unreachable or answers non-2xx. Every pairing subcommand
156
- * talks to the gateway this way, so the reachability + HTTP-error handling has
157
- * a single home.
169
+ * when the gateway is unreachable. Non-2xx responses are returned to the
170
+ * caller; use {@link gatewayPostOrExit} unless the call site prints its own
171
+ * HTTP-error diagnostics.
158
172
  */
159
- async function gatewayPostOrExit(
173
+ async function gatewayPost(
160
174
  gatewayUrl: string,
161
175
  path: string,
162
176
  body: unknown,
163
177
  headers?: Record<string, string>,
164
178
  ): Promise<Response> {
165
- let response: Response;
166
179
  try {
167
- response = await loopbackSafeFetch(`${gatewayUrl}${path}`, {
180
+ return await loopbackSafeFetch(`${gatewayUrl}${path}`, {
168
181
  method: "POST",
169
182
  headers: { "Content-Type": "application/json", ...headers },
170
183
  body: JSON.stringify(body),
@@ -177,7 +190,24 @@ async function gatewayPostOrExit(
177
190
  console.error("Is the assistant running? Try `vellum wake`.");
178
191
  process.exit(1);
179
192
  }
193
+ }
194
+
195
+ /**
196
+ * {@link gatewayPost}, but also exiting with a generic message on non-2xx.
197
+ * Every pairing subcommand talks to the gateway this way, so the reachability
198
+ * + HTTP-error handling has a single home.
199
+ */
200
+ async function gatewayPostOrExit(
201
+ gatewayUrl: string,
202
+ path: string,
203
+ body: unknown,
204
+ headers?: Record<string, string>,
205
+ ): Promise<Response> {
206
+ return exitOnHttpError(await gatewayPost(gatewayUrl, path, body, headers));
207
+ }
180
208
 
209
+ /** Exit with a generic HTTP-error message on non-2xx; pass 2xx through. */
210
+ async function exitOnHttpError(response: Response): Promise<Response> {
181
211
  if (!response.ok) {
182
212
  const errorBody = await response.text().catch(() => "");
183
213
  console.error(
@@ -185,7 +215,6 @@ async function gatewayPostOrExit(
185
215
  );
186
216
  process.exit(1);
187
217
  }
188
-
189
218
  return response;
190
219
  }
191
220
 
@@ -206,18 +235,32 @@ async function createRemoteWebPairingChallenge(
206
235
  }
207
236
 
208
237
  /**
209
- * Approve a pending pairing challenge by its user code the local-presence
210
- * proof for the device-code flow. Shared by `--web-approve` and `--qr` (which
211
- * approves the challenge it just minted so one scan completes pairing).
238
+ * Approve a pending pairing challenge by its user code, the local-presence
239
+ * proof for the device-code flow. Single owner of the pairing-verification
240
+ * route and request body: `--qr` approves via {@link approveRemoteWebPairing}
241
+ * (generic exit on non-2xx), while `--web-approve` calls this directly to
242
+ * inspect rejections and print mismatch diagnostics (see
243
+ * {@link formatWebApproveFailure}).
244
+ */
245
+ async function postPairingVerification(
246
+ gatewayUrl: string,
247
+ userCode: string,
248
+ ): Promise<Response> {
249
+ return gatewayPost(gatewayUrl, "/v1/remote-web/pairing-verification", {
250
+ userCode,
251
+ } satisfies RemoteWebPairingVerificationRequest);
252
+ }
253
+
254
+ /**
255
+ * Approve the challenge `--qr` just minted so one scan completes pairing,
256
+ * exiting with a generic message on non-2xx.
212
257
  */
213
258
  async function approveRemoteWebPairing(
214
259
  gatewayUrl: string,
215
260
  userCode: string,
216
261
  ): Promise<RemoteWebPairingVerificationResponse> {
217
- const response = await gatewayPostOrExit(
218
- gatewayUrl,
219
- "/v1/remote-web/pairing-verification",
220
- { userCode } satisfies RemoteWebPairingVerificationRequest,
262
+ const response = await exitOnHttpError(
263
+ await postPairingVerification(gatewayUrl, userCode),
221
264
  );
222
265
  return (await response.json()) as RemoteWebPairingVerificationResponse;
223
266
  }
@@ -399,7 +442,26 @@ export async function pair(): Promise<void> {
399
442
  if (webApproveCode) {
400
443
  await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
401
444
 
402
- const result = await approveRemoteWebPairing(mintUrl, webApproveCode);
445
+ // Rejections are diagnosed here rather than by exitOnHttpError: a
446
+ // rejected code must name the gateway that was asked, or an
447
+ // assistant/environment mismatch is indistinguishable from a typo.
448
+ const response = await postPairingVerification(mintUrl, webApproveCode);
449
+ if (!response.ok) {
450
+ const errorBody = await response.text().catch(() => "");
451
+ const diagnostic = formatWebApproveFailure(
452
+ mintUrl,
453
+ formatAssistantReference(entry),
454
+ getCurrentEnvironment().name,
455
+ parseGatewayErrorCode(errorBody),
456
+ );
457
+ console.error(
458
+ diagnostic ??
459
+ `Error: HTTP ${response.status}: ${errorBody || response.statusText}`,
460
+ );
461
+ process.exit(1);
462
+ }
463
+ const result =
464
+ (await response.json()) as RemoteWebPairingVerificationResponse;
403
465
  if (jsonOutput) {
404
466
  console.log(JSON.stringify(result, null, 2));
405
467
  return;
@@ -502,6 +564,7 @@ export async function pair(): Promise<void> {
502
564
  appSchemeOverride ?? DEFAULT_APP_CONNECT_SCHEME,
503
565
  qrBaseUrl,
504
566
  challenge.deviceCode,
567
+ label || assistantDisplayName(entry),
505
568
  )
506
569
  : null;
507
570
 
@@ -1,15 +1,26 @@
1
1
  import { join } from "path";
2
2
 
3
- import { resolveAssistant, type AssistantEntry } from "../lib/assistant-config";
3
+ import {
4
+ formatAssistantReference,
5
+ loadAllAssistants,
6
+ resolveTargetAssistant,
7
+ type AssistantEntry,
8
+ } from "../lib/assistant-config";
9
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
4
10
  import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
5
- import { GATEWAY_PORT } from "../lib/constants.js";
6
11
  import {
7
- isAssistantFeatureFlagEnabled,
8
- WEB_REMOTE_INGRESS_FLAG,
9
- } from "../lib/feature-flags.js";
12
+ getDefaultWorkspaceDir,
13
+ saveNgrokDomain,
14
+ } from "../lib/ingress-config.js";
15
+ import {
16
+ ensureTunnelEdge,
17
+ formatEdgeMode,
18
+ type TunnelEdge,
19
+ } from "../lib/nginx-ingress.js";
10
20
  import { runNgrokTunnel } from "../lib/ngrok";
11
21
  import { STALE_CLI_UPDATE_HINT } from "../lib/stale-cli-hint.js";
12
22
  import { runTailscaleTunnel } from "../lib/tailscale-tunnel.js";
23
+ import { parseGatewayPortFromEntryUrls } from "./nginx-ingress.js";
13
24
 
14
25
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
15
26
  type TunnelProvider = (typeof VALID_PROVIDERS)[number];
@@ -19,12 +30,17 @@ const DEFAULT_PROVIDER: TunnelProvider = "vellum";
19
30
  interface TunnelArgs {
20
31
  assistantName: string | null;
21
32
  provider: TunnelProvider;
33
+ domain: string | null;
34
+ clearDomain: boolean;
22
35
  }
23
36
 
37
+ const FLAGS_WITH_VALUES = ["--provider", "--domain"] as const;
38
+
24
39
  function parseArgs(): TunnelArgs {
25
40
  const args = process.argv.slice(3);
26
- let assistantName: string | null = null;
27
41
  let provider: TunnelProvider = DEFAULT_PROVIDER;
42
+ let domain: string | null = null;
43
+ let clearDomain = false;
28
44
 
29
45
  for (let i = 0; i < args.length; i++) {
30
46
  const arg = args[i];
@@ -41,6 +57,13 @@ function parseArgs(): TunnelArgs {
41
57
  "enabling webhook integrations (Telegram, Twilio, etc.) to reach the assistant.",
42
58
  );
43
59
  console.log("");
60
+ console.log(
61
+ "The tunnel always fronts the local nginx edge, which is started automatically.",
62
+ );
63
+ console.log(
64
+ "nginx must be installed (macOS: brew install nginx, Linux: sudo apt install nginx).",
65
+ );
66
+ console.log("");
44
67
  console.log("Arguments:");
45
68
  console.log(
46
69
  " <name> Name of the assistant (defaults to active or only local)",
@@ -50,6 +73,18 @@ function parseArgs(): TunnelArgs {
50
73
  console.log(
51
74
  ` --provider <provider> Tunnel provider: ${VALID_PROVIDERS.join(", ")} (default: ${DEFAULT_PROVIDER})`,
52
75
  );
76
+ console.log(
77
+ " --domain <domain> Reserved ngrok domain to bind (ngrok provider only).",
78
+ );
79
+ console.log(
80
+ " Saved to the workspace config so `vellum wake` restores reuse it.",
81
+ );
82
+ console.log(
83
+ " --clear-domain Clear the saved ngrok domain (ngrok provider only) and tunnel",
84
+ );
85
+ console.log(
86
+ " without one. Cannot be combined with --domain.",
87
+ );
53
88
  console.log("");
54
89
  console.log("Providers:");
55
90
  console.log(
@@ -72,8 +107,11 @@ function parseArgs(): TunnelArgs {
72
107
  );
73
108
  console.log("");
74
109
  console.log("Examples:");
75
- console.log(" $ vellum tunnel");
76
110
  console.log(" $ vellum tunnel --provider ngrok");
111
+ console.log(
112
+ " $ vellum tunnel --provider ngrok --domain my-assistant.ngrok.app",
113
+ );
114
+ console.log(" $ vellum tunnel --provider ngrok --clear-domain");
77
115
  console.log(" $ vellum tunnel --provider cloudflare");
78
116
  console.log(" $ vellum tunnel my-assistant --provider tailscale");
79
117
  process.exit(0);
@@ -96,121 +134,194 @@ function parseArgs(): TunnelArgs {
96
134
  }
97
135
  provider = next as TunnelProvider;
98
136
  i++;
137
+ } else if (arg === "--domain") {
138
+ const next = args[i + 1];
139
+ if (!next || next.startsWith("-")) {
140
+ console.error(
141
+ "Error: --domain requires a value, e.g. --domain my-assistant.ngrok.app",
142
+ );
143
+ process.exit(1);
144
+ }
145
+ domain = next;
146
+ i++;
147
+ } else if (arg === "--clear-domain") {
148
+ clearDomain = true;
99
149
  } else if (arg.startsWith("-")) {
100
150
  console.error(`Error: Unknown option '${arg}'.`);
101
151
  process.exit(1);
102
- } else if (!assistantName) {
103
- assistantName = arg;
104
- } else {
105
- console.error(`Error: Unexpected argument '${arg}'.`);
106
- process.exit(1);
107
152
  }
108
153
  }
109
154
 
110
- return { assistantName, provider };
155
+ // Joins all positionals so unquoted multi-word display names resolve as one
156
+ // identifier (cli/AGENTS.md "Assistant targeting convention").
157
+ const assistantName =
158
+ parseAssistantTargetArg(args, FLAGS_WITH_VALUES) ?? null;
159
+
160
+ if (domain && provider !== "ngrok") {
161
+ console.error(
162
+ `Error: --domain is only supported with --provider ngrok (got '${provider}').`,
163
+ );
164
+ process.exit(1);
165
+ }
166
+
167
+ if (clearDomain && provider !== "ngrok") {
168
+ console.error(
169
+ `Error: --clear-domain is only supported with --provider ngrok (got '${provider}').`,
170
+ );
171
+ process.exit(1);
172
+ }
173
+
174
+ if (clearDomain && domain) {
175
+ console.error(
176
+ "Error: --clear-domain cannot be combined with --domain. Pass --domain alone to replace the saved domain.",
177
+ );
178
+ process.exit(1);
179
+ }
180
+
181
+ return { assistantName, provider, domain, clearDomain };
111
182
  }
112
183
 
113
- function parsePortFromUrl(url: unknown): number | undefined {
114
- if (typeof url !== "string" || !url.trim()) return undefined;
115
- try {
116
- const port = Number(new URL(url).port);
117
- return Number.isInteger(port) && port > 0 && port <= 65535
118
- ? port
119
- : undefined;
120
- } catch {
121
- return undefined;
184
+ /** A tunnelable assistant plus the gateway port and workspace the edge fronts. */
185
+ interface LocalTunnelTarget {
186
+ entry: AssistantEntry;
187
+ gatewayPort: number;
188
+ workspaceDir: string;
189
+ }
190
+
191
+ /** Container topologies whose gateway runs on this machine without host `resources`. */
192
+ function isLocalContainerEntry(entry: AssistantEntry): boolean {
193
+ return entry.cloud === "docker" || entry.cloud === "apple-container";
194
+ }
195
+
196
+ /**
197
+ * Map an entry to its local tunnel target, or null when it has no locally
198
+ * reachable gateway (e.g. platform-hosted). Entries with `resources` carry
199
+ * their own gateway port and instance workspace. Local container entries
200
+ * (docker, apple-container) run locally without host resources: their gateway
201
+ * port comes from localUrl/runtimeUrl and their ingress state lives in the
202
+ * default workspace, matching the `vellum nginx-ingress` resolution for the
203
+ * same topology.
204
+ */
205
+ function toLocalTunnelTarget(entry: AssistantEntry): LocalTunnelTarget | null {
206
+ if (entry.resources) {
207
+ return {
208
+ entry,
209
+ gatewayPort: entry.resources.gatewayPort,
210
+ workspaceDir: join(entry.resources.instanceDir, ".vellum", "workspace"),
211
+ };
122
212
  }
213
+ if (isLocalContainerEntry(entry)) {
214
+ const gatewayPort = parseGatewayPortFromEntryUrls(entry);
215
+ if (gatewayPort !== undefined) {
216
+ return { entry, gatewayPort, workspaceDir: getDefaultWorkspaceDir() };
217
+ }
218
+ }
219
+ return null;
123
220
  }
124
221
 
125
- function resolveEntryGatewayPort(entry: AssistantEntry): number {
126
- return (
127
- entry.resources?.gatewayPort ??
128
- parsePortFromUrl(entry.localUrl) ??
129
- parsePortFromUrl(entry.runtimeUrl) ??
130
- GATEWAY_PORT
131
- );
222
+ function describeUntunnelableEntry(entry: AssistantEntry): string {
223
+ const reference = formatAssistantReference(entry);
224
+ return entry.cloud === "vellum"
225
+ ? `Assistant '${reference}' runs on Vellum Cloud and needs no tunnel.`
226
+ : `Assistant '${reference}' has no locally managed runtime to tunnel.`;
132
227
  }
133
228
 
134
- async function shouldPreferNginxIngress(
135
- assistantId: string,
136
- gatewayPort: number,
137
- ): Promise<boolean> {
138
- try {
139
- return await isAssistantFeatureFlagEnabled(
140
- assistantId,
141
- WEB_REMOTE_INGRESS_FLAG,
142
- { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
229
+ /**
230
+ * Resolve the assistant whose gateway and workspace the tunnel edge fronts.
231
+ * Tunnels only make sense for assistants with a local gateway; when the
232
+ * resolved entry has none (e.g. the active assistant is platform-hosted) and
233
+ * no name was given, fall back to the sole local target, otherwise exit with
234
+ * an error naming the local assistants to pass explicitly.
235
+ */
236
+ function resolveLocalTunnelTarget(
237
+ assistantName: string | null,
238
+ ): LocalTunnelTarget {
239
+ const entry = resolveTargetAssistant(assistantName ?? undefined);
240
+
241
+ const target = toLocalTunnelTarget(entry);
242
+ if (target) {
243
+ return target;
244
+ }
245
+
246
+ const localTargets = loadAllAssistants()
247
+ .map(toLocalTunnelTarget)
248
+ .filter((local): local is LocalTunnelTarget => local !== null);
249
+
250
+ if (!assistantName && localTargets.length === 1) {
251
+ console.log(
252
+ `${describeUntunnelableEntry(entry)} Tunneling the local assistant '${formatAssistantReference(localTargets[0].entry)}' instead.`,
143
253
  );
144
- } catch (err) {
145
- throw new Error(
146
- `Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag before starting the tunnel. Is the assistant running? Try \`vellum wake\` and retry. ${
147
- err instanceof Error ? err.message : String(err)
148
- }`,
254
+ return localTargets[0];
255
+ }
256
+
257
+ console.error(describeUntunnelableEntry(entry));
258
+ if (localTargets.length === 0) {
259
+ console.error(
260
+ "No local assistant found to tunnel. Run `vellum hatch` first.",
261
+ );
262
+ } else {
263
+ console.error(
264
+ `Pass a local assistant as the name argument: ${localTargets
265
+ .map((local) => formatAssistantReference(local.entry))
266
+ .join(", ")}.`,
149
267
  );
150
268
  }
269
+ process.exit(1);
151
270
  }
152
271
 
153
272
  export async function tunnel(): Promise<void> {
154
- const { assistantName, provider } = parseArgs();
273
+ const { assistantName, provider, domain, clearDomain } = parseArgs();
155
274
 
156
- const entry = resolveAssistant(assistantName ?? undefined);
275
+ if (provider === "vellum") {
276
+ throw new Error(
277
+ `Tunnel provider '${provider}' is not yet implemented. ` +
278
+ `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
279
+ );
280
+ }
157
281
 
158
- if (!entry) {
159
- if (assistantName) {
160
- console.error(
161
- `No assistant instance found with name '${assistantName}'.`,
162
- );
163
- } else {
164
- console.error("No assistant instance found. Run `vellum hatch` first.");
165
- }
282
+ const { entry, gatewayPort, workspaceDir } =
283
+ resolveLocalTunnelTarget(assistantName);
284
+
285
+ if (clearDomain) {
286
+ saveNgrokDomain(workspaceDir, null);
287
+ console.log("Cleared the saved ngrok domain from the workspace config.");
288
+ }
289
+
290
+ let edge: TunnelEdge;
291
+ try {
292
+ edge = await ensureTunnelEdge({
293
+ assistantId: entry.assistantId,
294
+ workspaceDir,
295
+ gatewayPort,
296
+ });
297
+ } catch (err) {
298
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
166
299
  process.exit(1);
167
300
  }
168
301
 
169
- const resources = entry.resources;
170
- const gatewayPort = resolveEntryGatewayPort(entry);
302
+ console.log(
303
+ `${edge.started ? "Started" : "Reusing"} the nginx edge on 127.0.0.1:${edge.port} ` +
304
+ `(serves ${formatEdgeMode(edge.includesWebApp)}).`,
305
+ );
306
+
171
307
  const baseTunnelOpts = {
172
- port: gatewayPort,
308
+ port: edge.port,
173
309
  assistantId: entry.assistantId,
174
- ...(resources
175
- ? { workspaceDir: join(resources.instanceDir, ".vellum", "workspace") }
176
- : {}),
310
+ workspaceDir,
177
311
  };
178
312
 
179
313
  if (provider === "ngrok") {
180
314
  await runNgrokTunnel({
181
315
  ...baseTunnelOpts,
182
- preferNginxIngress: await shouldPreferNginxIngress(
183
- entry.assistantId,
184
- gatewayPort,
185
- ),
316
+ ...(domain ? { domain } : {}),
186
317
  });
187
318
  return;
188
319
  }
189
320
 
190
321
  if (provider === "cloudflare") {
191
- await runCloudflareTunnel({
192
- ...baseTunnelOpts,
193
- preferNginxIngress: await shouldPreferNginxIngress(
194
- entry.assistantId,
195
- gatewayPort,
196
- ),
197
- });
322
+ await runCloudflareTunnel(baseTunnelOpts);
198
323
  return;
199
324
  }
200
325
 
201
- if (provider === "tailscale") {
202
- await runTailscaleTunnel({
203
- ...baseTunnelOpts,
204
- preferNginxIngress: await shouldPreferNginxIngress(
205
- entry.assistantId,
206
- gatewayPort,
207
- ),
208
- });
209
- return;
210
- }
211
-
212
- throw new Error(
213
- `Tunnel provider '${provider}' is not yet implemented. ` +
214
- `If this provider is documented, ${STALE_CLI_UPDATE_HINT}`,
215
- );
326
+ await runTailscaleTunnel(baseTunnelOpts);
216
327
  }
@@ -72,7 +72,7 @@ import {
72
72
  startLocalDaemon,
73
73
  stopLocalProcesses,
74
74
  } from "../lib/local.js";
75
- import { maybeStartNgrokTunnel } from "../lib/ngrok.js";
75
+ import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel-edge.js";
76
76
  import {
77
77
  leaseGuardianToken,
78
78
  resetGuardianBootstrap,
@@ -1044,7 +1044,8 @@ async function upgradeLocal(
1044
1044
  ".vellum",
1045
1045
  "workspace",
1046
1046
  );
1047
- const ngrokChild = await maybeStartNgrokTunnel(
1047
+ const ngrokChild = await restoreTunnelEdgeAndAutoTunnel(
1048
+ entry.assistantId,
1048
1049
  entry.resources.gatewayPort,
1049
1050
  workspaceDir,
1050
1051
  );