@vellumai/cli 0.7.0 → 0.7.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 (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
@@ -1,7 +1,14 @@
1
+ import type { AssistantEntry } from "./assistant-config.js";
1
2
  import {
3
+ authHeaders,
4
+ invalidateOrgIdCache,
2
5
  parseUnifiedJobStatus,
3
6
  type UnifiedJobStatus,
4
7
  } from "./platform-client.js";
8
+ import {
9
+ resolveRuntimeMigrationUrl,
10
+ resolveRuntimeUrl,
11
+ } from "./runtime-url.js";
5
12
 
6
13
  /**
7
14
  * Thrown when the local runtime returns 409 for an export/import request
@@ -34,6 +41,29 @@ function bearerHeaders(token: string): Record<string, string> {
34
41
  };
35
42
  }
36
43
 
44
+ /**
45
+ * Build the auth + content headers for a runtime migration request.
46
+ *
47
+ * - For `cloud === "vellum"` we go through the platform's wildcard runtime
48
+ * proxy, which authenticates user-session / vak_ tokens via DRF's default
49
+ * authentication classes — `authHeaders()` produces the right combination
50
+ * (`X-Session-Token` + `Vellum-Organization-Id`, or `Authorization: Bearer
51
+ * vak_...`).
52
+ * - For local/docker the runtime endpoint expects a guardian-token bearer.
53
+ */
54
+ async function migrationRequestHeaders(
55
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl">,
56
+ token: string,
57
+ ): Promise<Record<string, string>> {
58
+ if (entry.cloud === "vellum") {
59
+ return {
60
+ ...(await authHeaders(token, entry.runtimeUrl)),
61
+ Accept: "application/json",
62
+ };
63
+ }
64
+ return bearerHeaders(token);
65
+ }
66
+
37
67
  interface Raw409Body {
38
68
  detail?: string;
39
69
  // The runtime's current 409 contract nests the payload under `error`:
@@ -69,13 +99,21 @@ async function throwIfInProgress(
69
99
  }
70
100
 
71
101
  /**
72
- * Kick off an async export-to-GCS job on the local runtime.
73
- * POSTs to `{runtimeUrl}/v1/migrations/export-to-gcs` and returns the
74
- * 202-accepted job_id. On 409 (another export in flight) throws
75
- * {@link MigrationInProgressError} with the existing job_id.
102
+ * Kick off an async export-to-GCS job on the assistant's runtime.
103
+ *
104
+ * For local/docker assistants this POSTs to
105
+ * `{runtimeUrl}/v1/migrations/export-to-gcs` with guardian-token bearer
106
+ * auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
107
+ * to the wildcard-runtime-proxy shape
108
+ * `{platformUrl}/v1/assistants/<assistantId>/migrations/export-to-gcs` and
109
+ * authenticated via the platform-token header set the platform's DRF auth
110
+ * accepts (session / vak_).
111
+ *
112
+ * Returns the 202-accepted `job_id`. On 409 (another export in flight)
113
+ * throws {@link MigrationInProgressError} with the existing job_id.
76
114
  */
77
115
  export async function localRuntimeExportToGcs(
78
- runtimeUrl: string,
116
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
79
117
  token: string,
80
118
  params: { uploadUrl: string; description?: string },
81
119
  ): Promise<{ jobId: string }> {
@@ -84,11 +122,14 @@ export async function localRuntimeExportToGcs(
84
122
  body.description = params.description;
85
123
  }
86
124
 
87
- const response = await fetch(`${runtimeUrl}/v1/migrations/export-to-gcs`, {
88
- method: "POST",
89
- headers: bearerHeaders(token),
90
- body: JSON.stringify(body),
91
- });
125
+ const response = await fetch(
126
+ resolveRuntimeMigrationUrl(entry, "export-to-gcs"),
127
+ {
128
+ method: "POST",
129
+ headers: await migrationRequestHeaders(entry, token),
130
+ body: JSON.stringify(body),
131
+ },
132
+ );
92
133
 
93
134
  await throwIfInProgress(response, "export_in_progress");
94
135
 
@@ -110,20 +151,29 @@ export async function localRuntimeExportToGcs(
110
151
  }
111
152
 
112
153
  /**
113
- * Kick off an async import-from-GCS job on the local runtime.
114
- * POSTs to `{runtimeUrl}/v1/migrations/import-from-gcs` with a signed
115
- * download URL. On 409 throws {@link MigrationInProgressError}.
154
+ * Kick off an async import-from-GCS job on the assistant's runtime.
155
+ *
156
+ * For local/docker assistants this POSTs to
157
+ * `{runtimeUrl}/v1/migrations/import-from-gcs` with guardian-token bearer
158
+ * auth. For platform-managed (cloud="vellum") assistants the URL is rewritten
159
+ * to the wildcard-runtime-proxy shape
160
+ * `{platformUrl}/v1/assistants/<assistantId>/migrations/import-from-gcs` and
161
+ * authenticated via the platform token. On 409 throws
162
+ * {@link MigrationInProgressError}.
116
163
  */
117
164
  export async function localRuntimeImportFromGcs(
118
- runtimeUrl: string,
165
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
119
166
  token: string,
120
167
  params: { bundleUrl: string },
121
168
  ): Promise<{ jobId: string }> {
122
- const response = await fetch(`${runtimeUrl}/v1/migrations/import-from-gcs`, {
123
- method: "POST",
124
- headers: bearerHeaders(token),
125
- body: JSON.stringify({ bundle_url: params.bundleUrl }),
126
- });
169
+ const response = await fetch(
170
+ resolveRuntimeMigrationUrl(entry, "import-from-gcs"),
171
+ {
172
+ method: "POST",
173
+ headers: await migrationRequestHeaders(entry, token),
174
+ body: JSON.stringify({ bundle_url: params.bundleUrl }),
175
+ },
176
+ );
127
177
 
128
178
  await throwIfInProgress(response, "import_in_progress");
129
179
 
@@ -145,21 +195,28 @@ export async function localRuntimeImportFromGcs(
145
195
  }
146
196
 
147
197
  /**
148
- * Poll the local runtime's unified job-status endpoint.
149
- * GETs `{runtimeUrl}/v1/migrations/jobs/{jobId}` and parses into
150
- * {@link UnifiedJobStatus}.
198
+ * Poll the runtime's unified job-status endpoint.
199
+ *
200
+ * For local/docker assistants this GETs
201
+ * `{runtimeUrl}/v1/migrations/jobs/{jobId}` directly (guardian-token
202
+ * bearer). For platform-managed assistants it routes through the wildcard
203
+ * runtime proxy at
204
+ * `{platformUrl}/v1/assistants/<assistantId>/migrations/jobs/{jobId}` with
205
+ * platform-token auth — important: the platform's dedicated
206
+ * `/v1/migrations/jobs/{id}/` endpoint queries platform-side ImportJob
207
+ * records and would 404 on runtime-created job IDs.
151
208
  */
152
209
  export async function localRuntimePollJobStatus(
153
- runtimeUrl: string,
210
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
154
211
  token: string,
155
212
  jobId: string,
156
213
  ): Promise<UnifiedJobStatus> {
157
- const response = await fetch(`${runtimeUrl}/v1/migrations/jobs/${jobId}`, {
158
- headers: {
159
- Authorization: `Bearer ${token}`,
160
- Accept: "application/json",
214
+ const response = await fetch(
215
+ resolveRuntimeMigrationUrl(entry, `jobs/${jobId}`),
216
+ {
217
+ headers: await migrationRequestHeaders(entry, token),
161
218
  },
162
- });
219
+ );
163
220
 
164
221
  if (response.status === 404) {
165
222
  throw new Error("Migration job not found");
@@ -176,3 +233,80 @@ export async function localRuntimePollJobStatus(
176
233
  >[0];
177
234
  return parseUnifiedJobStatus(raw);
178
235
  }
236
+
237
+ /**
238
+ * The subset of `/v1/health` we care about. The runtime's full response
239
+ * includes additional fields (status, disk, memory, cpu, migrations, etc.)
240
+ * — we only model `version` here because that's all the CLI consumes today.
241
+ */
242
+ export interface RuntimeIdentity {
243
+ version: string;
244
+ }
245
+
246
+ /**
247
+ * Fetch the target runtime's APP_VERSION via `/v1/health`. Used by
248
+ * `vellum teleport` and `vellum backup` to stamp the exported bundle's
249
+ * `min_runtime_version` with the version of the runtime that actually
250
+ * produced it — which can diverge from the orchestrating CLI's version when
251
+ * the target was upgraded independently.
252
+ *
253
+ * GETs `/v1/health` (not `/v1/identity`) so the call works on freshly-
254
+ * hatched runtimes that haven't completed onboarding. The `/v1/identity`
255
+ * handler reads `IDENTITY.md` from the workspace and 404s if it's missing
256
+ * — and `IDENTITY.md` is only written during onboarding, not hatch. The
257
+ * `/v1/health` handler returns the same `version` field unconditionally
258
+ * (no filesystem reads), so it's safe to call against any running runtime.
259
+ *
260
+ * For local/docker assistants this GETs `{runtimeUrl}/v1/health` with
261
+ * guardian-token bearer auth. For platform-managed (cloud="vellum")
262
+ * assistants the URL is rewritten to the wildcard runtime proxy shape
263
+ * `{platformUrl}/v1/assistants/<assistantId>/health` and authenticated via
264
+ * the platform token.
265
+ *
266
+ * For the vellum target this is the FIRST network call in the
267
+ * teleport/backup export flow, so a stale `Vellum-Organization-Id` cache
268
+ * entry would surface as a hard abort before any retry-friendly call (like
269
+ * `platformRequestSignedUrl`) gets a chance to recover. Mirror that helper's
270
+ * one-shot 401-retry: invalidate the org-ID cache and retry once. Local /
271
+ * docker entries do not use the org-ID cache and are wrapped in
272
+ * `callRuntimeWithAuthRetry` by callers for guardian-token refresh, so the
273
+ * retry is intentionally vellum-only.
274
+ *
275
+ * The function name is intentionally retained ("identity-ish info about the
276
+ * runtime") even though the implementation now hits `/v1/health` — renaming
277
+ * would force changes in 4+ callsites for no behavioral benefit.
278
+ *
279
+ * Throws on non-2xx so callers can surface the failure (we never silently
280
+ * fall back — see teleport.ts call site).
281
+ */
282
+ export async function localRuntimeIdentity(
283
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
284
+ token: string,
285
+ ): Promise<RuntimeIdentity> {
286
+ const url = resolveRuntimeUrl(entry, "health");
287
+ const doRequest = async (): Promise<Response> =>
288
+ fetch(url, {
289
+ method: "GET",
290
+ headers: await migrationRequestHeaders(entry, token),
291
+ });
292
+
293
+ let response = await doRequest();
294
+ if (response.status === 401 && entry.cloud === "vellum") {
295
+ // `entry.runtimeUrl` is the platform host for vellum-cloud entries
296
+ // (the wildcard runtime proxy lives there). Pass it as the cache key
297
+ // platformUrl so we invalidate the same entry that authHeaders cached.
298
+ invalidateOrgIdCache(token, entry.runtimeUrl);
299
+ response = await doRequest();
300
+ }
301
+
302
+ if (!response.ok) {
303
+ throw new Error(
304
+ `Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
305
+ );
306
+ }
307
+ const body = (await response.json()) as { version?: unknown };
308
+ if (typeof body.version !== "string" || !body.version) {
309
+ throw new Error("Runtime identity response missing version");
310
+ }
311
+ return { version: body.version };
312
+ }
package/src/lib/local.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  writeFileSync,
9
9
  } from "fs";
10
10
  import { createRequire } from "module";
11
- import { homedir, hostname, networkInterfaces, platform, tmpdir } from "os";
11
+ import { homedir, networkInterfaces, platform, tmpdir } from "os";
12
12
  import { dirname, join } from "path";
13
13
 
14
14
  import {
@@ -111,7 +111,9 @@ function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
111
111
  * a short override directory and set all IPC socket env vars on the target
112
112
  * env object. No-op on non-macOS or when paths are within limits.
113
113
  */
114
- function applyIpcSocketDirOverride(env: Record<string, string>): void {
114
+ function applyIpcSocketDirOverride(
115
+ env: Record<string, string | undefined>,
116
+ ): void {
115
117
  const workspaceDir =
116
118
  env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
117
119
  const override = computeIpcSocketDirOverride(workspaceDir);
@@ -392,7 +394,6 @@ async function startDaemonFromSource(
392
394
  : {}),
393
395
  };
394
396
  if (resources) {
395
- env.BASE_DATA_DIR = resources.instanceDir;
396
397
  env.VELLUM_WORKSPACE_DIR = join(
397
398
  resources.instanceDir,
398
399
  ".vellum",
@@ -403,6 +404,11 @@ async function startDaemonFromSource(
403
404
  ".vellum",
404
405
  "protected",
405
406
  );
407
+ env.CREDENTIAL_SECURITY_DIR = join(
408
+ resources.instanceDir,
409
+ ".vellum",
410
+ "protected",
411
+ );
406
412
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
407
413
  env.GATEWAY_PORT = String(resources.gatewayPort);
408
414
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -413,6 +419,8 @@ async function startDaemonFromSource(
413
419
  options.defaultWorkspaceConfigPath;
414
420
  }
415
421
 
422
+ applyIpcSocketDirOverride(env);
423
+
416
424
  // Write a sentinel PID file before spawning so concurrent hatch() calls
417
425
  // detect the in-progress spawn and wait instead of racing.
418
426
  writeFileSync(pidFile, "starting", "utf-8");
@@ -523,7 +531,6 @@ async function startDaemonWatchFromSource(
523
531
  : {}),
524
532
  };
525
533
  if (resources) {
526
- env.BASE_DATA_DIR = resources.instanceDir;
527
534
  env.VELLUM_WORKSPACE_DIR = join(
528
535
  resources.instanceDir,
529
536
  ".vellum",
@@ -534,6 +541,11 @@ async function startDaemonWatchFromSource(
534
541
  ".vellum",
535
542
  "protected",
536
543
  );
544
+ env.CREDENTIAL_SECURITY_DIR = join(
545
+ resources.instanceDir,
546
+ ".vellum",
547
+ "protected",
548
+ );
537
549
  env.RUNTIME_HTTP_PORT = String(resources.daemonPort);
538
550
  env.GATEWAY_PORT = String(resources.gatewayPort);
539
551
  env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -544,6 +556,8 @@ async function startDaemonWatchFromSource(
544
556
  options.defaultWorkspaceConfigPath;
545
557
  }
546
558
 
559
+ applyIpcSocketDirOverride(env);
560
+
547
561
  // Write a sentinel PID file before spawning so concurrent hatch() calls
548
562
  // detect the in-progress spawn and wait instead of racing.
549
563
  writeFileSync(pidFile, "starting", "utf-8");
@@ -675,51 +689,18 @@ export async function discoverPublicUrl(
675
689
  return `http://${cloudIp}:${effectivePort}`;
676
690
  }
677
691
 
678
- // Log the local address source only when we actually use it.
679
- if (localResult.source === "hostname") {
680
- console.log(` Discovered macOS local hostname: ${localResult.label}`);
681
- } else if (localResult.source === "lan") {
682
- console.log(` Discovered LAN IP: ${localResult.label}`);
683
- }
684
-
685
692
  return localResult.url;
686
693
  }
687
694
 
688
695
  /**
689
- * Resolve a LAN-reachable URL without any async I/O. Returns the best local
690
- * address or falls back to localhost. Does not emit any logs — the caller
691
- * decides whether to log based on which result is actually used.
696
+ * Returns the localhost URL for the gateway on the given port.
692
697
  */
693
698
  function discoverLocalUrl(effectivePort: number): {
694
699
  url: string;
695
- source: "hostname" | "lan" | "localhost";
696
- label?: string;
700
+ source: "localhost";
697
701
  } {
698
- // On macOS, prefer the .local hostname (Bonjour/mDNS) so other devices on
699
- // the same network can reach the gateway by name.
700
- if (platform() === "darwin") {
701
- const localHostname = getMacLocalHostname();
702
- if (localHostname) {
703
- return {
704
- url: `http://${localHostname}:${effectivePort}`,
705
- source: "hostname",
706
- label: localHostname,
707
- };
708
- }
709
- }
710
-
711
- const lanIp = getLocalLanIPv4();
712
- if (lanIp) {
713
- return {
714
- url: `http://${lanIp}:${effectivePort}`,
715
- source: "lan",
716
- label: lanIp,
717
- };
718
- }
719
-
720
- // Final fallback to localhost when no LAN address could be discovered.
721
702
  return {
722
- url: `http://localhost:${effectivePort}`,
703
+ url: `http://127.0.0.1:${effectivePort}`,
723
704
  source: "localhost",
724
705
  };
725
706
  }
@@ -776,19 +757,6 @@ async function discoverCloudExternalIp(): Promise<string | undefined> {
776
757
  return gcpIp ?? awsIp;
777
758
  }
778
759
 
779
- /**
780
- * Returns the macOS Bonjour/mDNS `.local` hostname (e.g. "Vargass-Mac-Mini.local"),
781
- * or undefined if not running on macOS or the hostname cannot be determined.
782
- */
783
- export function getMacLocalHostname(): string | undefined {
784
- const host = hostname();
785
- if (!host) return undefined;
786
- // macOS hostnames already end with .local when Bonjour is active
787
- if (host.endsWith(".local")) return host;
788
- // Otherwise, append .local — macOS resolves <ComputerName>.local via mDNS
789
- return `${host}.local`;
790
- }
791
-
792
760
  /**
793
761
  * Returns the local IPv4 address most likely to be reachable from other
794
762
  * devices on the same LAN.
@@ -988,8 +956,8 @@ export async function startLocalDaemon(
988
956
  for (const key of [
989
957
  "ANTHROPIC_API_KEY",
990
958
  "APP_VERSION",
991
- "BASE_DATA_DIR",
992
959
  "GATEWAY_SECURITY_DIR",
960
+ "CREDENTIAL_SECURITY_DIR",
993
961
  "VELLUM_ENVIRONMENT",
994
962
  "VELLUM_PLATFORM_URL",
995
963
  "QDRANT_HTTP_PORT",
@@ -1015,7 +983,6 @@ export async function startLocalDaemon(
1015
983
  // When running a named instance, override env so the daemon resolves
1016
984
  // all paths under the instance directory and listens on its own port.
1017
985
  if (resources) {
1018
- daemonEnv.BASE_DATA_DIR = resources.instanceDir;
1019
986
  daemonEnv.VELLUM_WORKSPACE_DIR = join(
1020
987
  resources.instanceDir,
1021
988
  ".vellum",
@@ -1026,6 +993,11 @@ export async function startLocalDaemon(
1026
993
  ".vellum",
1027
994
  "protected",
1028
995
  );
996
+ daemonEnv.CREDENTIAL_SECURITY_DIR = join(
997
+ resources.instanceDir,
998
+ ".vellum",
999
+ "protected",
1000
+ );
1029
1001
  daemonEnv.RUNTIME_HTTP_PORT = String(resources.daemonPort);
1030
1002
  daemonEnv.GATEWAY_PORT = String(resources.gatewayPort);
1031
1003
  daemonEnv.QDRANT_HTTP_PORT = String(resources.qdrantPort);
@@ -1165,7 +1137,7 @@ export async function startGateway(
1165
1137
 
1166
1138
  const publicUrl = await discoverPublicUrl(effectiveGatewayPort);
1167
1139
  if (publicUrl) {
1168
- console.log(` Public URL: ${publicUrl}`);
1140
+ console.log(` HTTP URL: ${publicUrl}`);
1169
1141
  }
1170
1142
 
1171
1143
  console.log("🌐 Starting gateway...");
@@ -1179,7 +1151,6 @@ export async function startGateway(
1179
1151
  GATEWAY_PORT: String(effectiveGatewayPort),
1180
1152
  // Pass gateway operational settings via env vars so the CLI does not
1181
1153
  // need direct access to the workspace config file.
1182
- RUNTIME_PROXY_ENABLED: "true",
1183
1154
  RUNTIME_PROXY_REQUIRE_AUTH: "true",
1184
1155
  UNMAPPED_POLICY: "default",
1185
1156
  DEFAULT_ASSISTANT_ID: "self",
@@ -1197,7 +1168,6 @@ export async function startGateway(
1197
1168
  // assistant DB directly for guardian bootstrap.
1198
1169
  ...(resources
1199
1170
  ? {
1200
- BASE_DATA_DIR: resources.instanceDir,
1201
1171
  VELLUM_WORKSPACE_DIR: join(
1202
1172
  resources.instanceDir,
1203
1173
  ".vellum",
@@ -1208,16 +1178,17 @@ export async function startGateway(
1208
1178
  ".vellum",
1209
1179
  "protected",
1210
1180
  ),
1181
+ CREDENTIAL_SECURITY_DIR: join(
1182
+ resources.instanceDir,
1183
+ ".vellum",
1184
+ "protected",
1185
+ ),
1211
1186
  }
1212
1187
  : {}),
1213
1188
  };
1214
1189
 
1215
1190
  applyIpcSocketDirOverride(gatewayEnv);
1216
1191
 
1217
- if (publicUrl) {
1218
- console.log(` Ingress URL: ${publicUrl}`);
1219
- }
1220
-
1221
1192
  let gateway;
1222
1193
 
1223
1194
  const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
@@ -1263,8 +1234,8 @@ export async function startGateway(
1263
1234
  const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
1264
1235
 
1265
1236
  // Wait for the gateway to be responsive before returning. Without this,
1266
- // callers (e.g. displayPairingQRCode) may try to connect before the HTTP
1267
- // server is listening and get connection-refused errors.
1237
+ // callers may try to connect before the HTTP server is listening and get
1238
+ // connection-refused errors.
1268
1239
  const start = Date.now();
1269
1240
  const timeoutMs = 30000;
1270
1241
  let ready = false;
package/src/lib/ngrok.ts CHANGED
@@ -12,13 +12,19 @@ import { dirname, join } from "node:path";
12
12
 
13
13
  import { GATEWAY_PORT } from "./constants";
14
14
 
15
- function getConfigPath(): string {
16
- const root = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
17
- return join(root, "workspace", "config.json");
15
+ function getDefaultWorkspaceDir(): string {
16
+ return (
17
+ process.env.VELLUM_WORKSPACE_DIR?.trim() ||
18
+ join(homedir(), ".vellum", "workspace")
19
+ );
20
+ }
21
+
22
+ function getConfigPath(workspaceDir: string): string {
23
+ return join(workspaceDir, "config.json");
18
24
  }
19
25
 
20
- function loadRawConfig(): Record<string, unknown> {
21
- const configPath = getConfigPath();
26
+ function loadRawConfig(workspaceDir: string): Record<string, unknown> {
27
+ const configPath = getConfigPath(workspaceDir);
22
28
  if (!existsSync(configPath)) return {};
23
29
  return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
24
30
  string,
@@ -26,8 +32,11 @@ function loadRawConfig(): Record<string, unknown> {
26
32
  >;
27
33
  }
28
34
 
29
- function saveRawConfig(config: Record<string, unknown>): void {
30
- const configPath = getConfigPath();
35
+ function saveRawConfig(
36
+ workspaceDir: string,
37
+ config: Record<string, unknown>,
38
+ ): void {
39
+ const configPath = getConfigPath(workspaceDir);
31
40
  const dir = dirname(configPath);
32
41
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
33
42
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
@@ -182,33 +191,33 @@ export async function waitForNgrokUrl(
182
191
  /**
183
192
  * Persist a public ingress URL to the workspace config and enable ingress.
184
193
  */
185
- function saveIngressUrl(publicUrl: string): void {
186
- const config = loadRawConfig();
194
+ function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
195
+ const config = loadRawConfig(workspaceDir);
187
196
  const ingress = (config.ingress ?? {}) as Record<string, unknown>;
188
197
  ingress.publicBaseUrl = publicUrl;
189
198
  ingress.enabled = true;
190
199
  config.ingress = ingress;
191
- saveRawConfig(config);
200
+ saveRawConfig(workspaceDir, config);
192
201
  }
193
202
 
194
203
  /**
195
204
  * Clear the ingress public base URL from the workspace config.
196
205
  */
197
- function clearIngressUrl(): void {
198
- const config = loadRawConfig();
206
+ function clearIngressUrl(workspaceDir: string): void {
207
+ const config = loadRawConfig(workspaceDir);
199
208
  const ingress = (config.ingress ?? {}) as Record<string, unknown>;
200
209
  delete ingress.publicBaseUrl;
201
210
  config.ingress = ingress;
202
- saveRawConfig(config);
211
+ saveRawConfig(workspaceDir, config);
203
212
  }
204
213
 
205
214
  /**
206
215
  * Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
207
216
  * configured that require a public ingress URL.
208
217
  */
209
- function hasWebhookIntegrationsConfigured(): boolean {
218
+ function hasWebhookIntegrationsConfigured(workspaceDir: string): boolean {
210
219
  try {
211
- const config = loadRawConfig();
220
+ const config = loadRawConfig(workspaceDir);
212
221
  const telegram = config.telegram as Record<string, unknown> | undefined;
213
222
  if (telegram?.botUsername) return true;
214
223
  const twilio = config.twilio as Record<string, unknown> | undefined;
@@ -223,9 +232,9 @@ function hasWebhookIntegrationsConfigured(): boolean {
223
232
  * Check whether a non-ngrok ingress URL is already configured (e.g. custom
224
233
  * domain or cloud deployment), meaning ngrok is not needed.
225
234
  */
226
- function hasNonNgrokIngressUrl(): boolean {
235
+ function hasNonNgrokIngressUrl(workspaceDir: string): boolean {
227
236
  try {
228
- const config = loadRawConfig();
237
+ const config = loadRawConfig(workspaceDir);
229
238
  const ingress = config.ingress as Record<string, unknown> | undefined;
230
239
  const publicBaseUrl = ingress?.publicBaseUrl;
231
240
  if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
@@ -244,6 +253,7 @@ function hasNonNgrokIngressUrl(): boolean {
244
253
  */
245
254
  export async function maybeStartNgrokTunnel(
246
255
  targetPort: number,
256
+ workspaceDir: string,
247
257
  ): Promise<ChildProcess | null> {
248
258
  // Managed/containerized deployments route webhooks through the platform's
249
259
  // callback proxy. ngrok is not needed and would not be reachable from the
@@ -252,8 +262,8 @@ export async function maybeStartNgrokTunnel(
252
262
  process.env.IS_CONTAINERIZED === "true" ||
253
263
  process.env.IS_CONTAINERIZED === "1";
254
264
  if (isContainerized) return null;
255
- if (!hasWebhookIntegrationsConfigured()) return null;
256
- if (hasNonNgrokIngressUrl()) return null;
265
+ if (!hasWebhookIntegrationsConfigured(workspaceDir)) return null;
266
+ if (hasNonNgrokIngressUrl(workspaceDir)) return null;
257
267
 
258
268
  const version = getNgrokVersion();
259
269
  if (!version) return null;
@@ -262,7 +272,7 @@ export async function maybeStartNgrokTunnel(
262
272
  const existingUrl = await findExistingTunnel(targetPort);
263
273
  if (existingUrl) {
264
274
  console.log(` Found existing ngrok tunnel: ${existingUrl}`);
265
- saveIngressUrl(existingUrl);
275
+ saveIngressUrl(workspaceDir, existingUrl);
266
276
  return null;
267
277
  }
268
278
 
@@ -274,14 +284,13 @@ export async function maybeStartNgrokTunnel(
274
284
  // 2. If pipe handles are destroyed, SIGPIPE kills ngrok on its next write.
275
285
  // Writing to a log file sidesteps both issues — the file descriptor is
276
286
  // inherited by the detached ngrok process and remains valid after CLI exit.
277
- const root = join(process.env.BASE_DATA_DIR?.trim() || homedir(), ".vellum");
278
- const ngrokLogPath = join(root, "workspace", "data", "logs", "ngrok.log");
287
+ const ngrokLogPath = join(workspaceDir, "data", "logs", "ngrok.log");
279
288
  const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
280
289
  ngrokProcess.unref();
281
290
 
282
291
  try {
283
292
  const publicUrl = await waitForNgrokUrl();
284
- saveIngressUrl(publicUrl);
293
+ saveIngressUrl(workspaceDir, publicUrl);
285
294
  console.log(` Tunnel established: ${publicUrl}`);
286
295
 
287
296
  return ngrokProcess;
@@ -317,12 +326,13 @@ export async function runNgrokTunnel(): Promise<void> {
317
326
  console.log(`Using ${version}`);
318
327
 
319
328
  const port = GATEWAY_PORT;
329
+ const workspaceDir = getDefaultWorkspaceDir();
320
330
 
321
331
  // Check for an existing ngrok tunnel pointing at the gateway
322
332
  const existingUrl = await findExistingTunnel(port);
323
333
  if (existingUrl) {
324
334
  console.log(`Found existing ngrok tunnel: ${existingUrl}`);
325
- saveIngressUrl(existingUrl);
335
+ saveIngressUrl(workspaceDir, existingUrl);
326
336
  console.log("Ingress URL saved to config.");
327
337
  console.log("");
328
338
  console.log(
@@ -349,7 +359,7 @@ export async function runNgrokTunnel(): Promise<void> {
349
359
  }
350
360
  if (publicUrl) {
351
361
  console.log("\nClearing ingress URL from config...");
352
- clearIngressUrl();
362
+ clearIngressUrl(workspaceDir);
353
363
  }
354
364
  };
355
365
 
@@ -398,7 +408,7 @@ export async function runNgrokTunnel(): Promise<void> {
398
408
  console.log(`Tunnel established: ${publicUrl}`);
399
409
  console.log(`Forwarding to: localhost:${port}`);
400
410
 
401
- saveIngressUrl(publicUrl);
411
+ saveIngressUrl(workspaceDir, publicUrl);
402
412
  console.log("Ingress URL saved to config.");
403
413
  console.log("");
404
414
  console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");