@vellumai/cli 0.7.1 → 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.
@@ -20,9 +20,11 @@ const DEVICE_ID_SALT = "vellum-assistant-host-id";
20
20
  export interface GuardianTokenData {
21
21
  guardianPrincipalId: string;
22
22
  accessToken: string;
23
- accessTokenExpiresAt: string;
23
+ /** ISO date string or epoch-ms number as returned by the gateway. */
24
+ accessTokenExpiresAt: string | number;
24
25
  refreshToken: string;
25
- refreshTokenExpiresAt: string;
26
+ /** ISO date string or epoch-ms number as returned by the gateway. */
27
+ refreshTokenExpiresAt: string | number;
26
28
  refreshAfter: string;
27
29
  isNew: boolean;
28
30
  deviceId: string;
@@ -104,7 +106,7 @@ export function getOrCreatePersistedDeviceId(): string {
104
106
  /**
105
107
  * Compute a stable device identifier matching the native client conventions.
106
108
  *
107
- * - macOS: SHA-256 of IOPlatformUUID + salt (matches PairingQRCodeSheet.computeHostId)
109
+ * - macOS: SHA-256 of IOPlatformUUID + salt
108
110
  * - Linux: SHA-256 of /etc/machine-id + salt
109
111
  * - Windows: SHA-256 of HKLM MachineGuid + salt
110
112
  * - Fallback: persisted random UUID in XDG config
@@ -158,6 +160,54 @@ export function saveGuardianToken(
158
160
  chmodSync(tokenPath, 0o600);
159
161
  }
160
162
 
163
+ /**
164
+ * Call POST /v1/guardian/refresh on the remote gateway to obtain a new
165
+ * access token using an existing (possibly expired) access token for auth.
166
+ * Returns the refreshed token data (persisted locally), or null if the
167
+ * refresh fails (e.g. no stored token, or refresh token itself is expired).
168
+ */
169
+ export async function refreshGuardianToken(
170
+ gatewayUrl: string,
171
+ assistantId: string,
172
+ ): Promise<GuardianTokenData | null> {
173
+ const tokenData = loadGuardianToken(assistantId);
174
+ if (!tokenData) return null;
175
+
176
+ // Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
177
+ // returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
178
+ const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
179
+ if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now()) return null;
180
+
181
+ try {
182
+ const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
183
+ method: "POST",
184
+ headers: {
185
+ "Content-Type": "application/json",
186
+ Authorization: `Bearer ${tokenData.accessToken}`,
187
+ },
188
+ body: JSON.stringify({ refreshToken: tokenData.refreshToken }),
189
+ });
190
+ if (!response.ok) return null;
191
+
192
+ const json = (await response.json()) as Record<string, unknown>;
193
+ const refreshed: GuardianTokenData = {
194
+ guardianPrincipalId: (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
195
+ accessToken: json.accessToken as string,
196
+ accessTokenExpiresAt: (json.accessTokenExpiresAt as string | number) ?? tokenData.accessTokenExpiresAt,
197
+ refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
198
+ refreshTokenExpiresAt: (json.refreshTokenExpiresAt as string | number) ?? tokenData.refreshTokenExpiresAt,
199
+ refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
200
+ isNew: false,
201
+ deviceId: tokenData.deviceId,
202
+ leasedAt: new Date().toISOString(),
203
+ };
204
+ saveGuardianToken(assistantId, refreshed);
205
+ return refreshed;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
161
211
  /**
162
212
  * Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
163
213
  * credential pair. The returned tokens are persisted locally under
@@ -190,9 +240,9 @@ export async function leaseGuardianToken(
190
240
  const tokenData: GuardianTokenData = {
191
241
  guardianPrincipalId: json.guardianPrincipalId as string,
192
242
  accessToken: json.accessToken as string,
193
- accessTokenExpiresAt: json.accessTokenExpiresAt as string,
243
+ accessTokenExpiresAt: json.accessTokenExpiresAt as string | number,
194
244
  refreshToken: json.refreshToken as string,
195
- refreshTokenExpiresAt: json.refreshTokenExpiresAt as string,
245
+ refreshTokenExpiresAt: json.refreshTokenExpiresAt as string | number,
196
246
  refreshAfter: json.refreshAfter as string,
197
247
  isNew: json.isNew as boolean,
198
248
  deviceId,
@@ -248,7 +298,7 @@ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
248
298
  try {
249
299
  const raw = readFileSync(sibling);
250
300
  const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
251
- const refreshExpiry = Date.parse(parsed.refreshTokenExpiresAt);
301
+ const refreshExpiry = new Date(parsed.refreshTokenExpiresAt).getTime();
252
302
  if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
253
303
  const dir = dirname(destPath);
254
304
  if (!existsSync(dir)) {
@@ -5,7 +5,6 @@ import {
5
5
  readlinkSync,
6
6
  symlinkSync,
7
7
  unlinkSync,
8
- writeFileSync,
9
8
  appendFileSync,
10
9
  readFileSync,
11
10
  } from "fs";
@@ -20,7 +19,6 @@ import {
20
19
  findAssistantByName,
21
20
  saveAssistantEntry,
22
21
  setActiveAssistant,
23
- syncConfigToLockfile,
24
22
  } from "./assistant-config.js";
25
23
  import type { AssistantEntry } from "./assistant-config.js";
26
24
  import type { Species } from "./constants.js";
@@ -31,7 +29,6 @@ import {
31
29
  startGateway,
32
30
  stopLocalProcesses,
33
31
  } from "./local.js";
34
- import { maybeStartNgrokTunnel } from "./ngrok.js";
35
32
 
36
33
  import { generateInstanceName } from "./random-name.js";
37
34
  import { leaseGuardianToken } from "./guardian-token.js";
@@ -223,9 +220,6 @@ export async function hatchLocal(
223
220
  }
224
221
 
225
222
  // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
226
- // Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
227
- // lockfile save/sync inside the same scope so syncConfigToLockfile() reads
228
- // this instance's workspace/config.json rather than a stale default path.
229
223
  const localEntry: AssistantEntry = {
230
224
  assistantId: instanceName,
231
225
  runtimeUrl,
@@ -236,26 +230,9 @@ export async function hatchLocal(
236
230
  resources: { ...resources, signingKey },
237
231
  };
238
232
 
239
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
240
- process.env.BASE_DATA_DIR = resources.instanceDir;
241
- try {
242
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
243
- if (ngrokChild?.pid) {
244
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
245
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
246
- }
247
-
248
- emitProgress(6, 6, "Saving configuration...");
249
- saveAssistantEntry(localEntry);
250
- setActiveAssistant(instanceName);
251
- syncConfigToLockfile();
252
- } finally {
253
- if (prevBaseDataDir !== undefined) {
254
- process.env.BASE_DATA_DIR = prevBaseDataDir;
255
- } else {
256
- delete process.env.BASE_DATA_DIR;
257
- }
258
- }
233
+ emitProgress(6, 6, "Saving configuration...");
234
+ saveAssistantEntry(localEntry);
235
+ setActiveAssistant(instanceName);
259
236
 
260
237
  if (process.env.VELLUM_DESKTOP_APP) {
261
238
  installCLISymlink();
@@ -1,10 +1,14 @@
1
1
  import type { AssistantEntry } from "./assistant-config.js";
2
2
  import {
3
3
  authHeaders,
4
+ invalidateOrgIdCache,
4
5
  parseUnifiedJobStatus,
5
6
  type UnifiedJobStatus,
6
7
  } from "./platform-client.js";
7
- import { resolveRuntimeMigrationUrl } from "./runtime-url.js";
8
+ import {
9
+ resolveRuntimeMigrationUrl,
10
+ resolveRuntimeUrl,
11
+ } from "./runtime-url.js";
8
12
 
9
13
  /**
10
14
  * Thrown when the local runtime returns 409 for an export/import request
@@ -229,3 +233,80 @@ export async function localRuntimePollJobStatus(
229
233
  >[0];
230
234
  return parseUnifiedJobStatus(raw);
231
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
@@ -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);
@@ -417,6 +419,8 @@ async function startDaemonFromSource(
417
419
  options.defaultWorkspaceConfigPath;
418
420
  }
419
421
 
422
+ applyIpcSocketDirOverride(env);
423
+
420
424
  // Write a sentinel PID file before spawning so concurrent hatch() calls
421
425
  // detect the in-progress spawn and wait instead of racing.
422
426
  writeFileSync(pidFile, "starting", "utf-8");
@@ -552,6 +556,8 @@ async function startDaemonWatchFromSource(
552
556
  options.defaultWorkspaceConfigPath;
553
557
  }
554
558
 
559
+ applyIpcSocketDirOverride(env);
560
+
555
561
  // Write a sentinel PID file before spawning so concurrent hatch() calls
556
562
  // detect the in-progress spawn and wait instead of racing.
557
563
  writeFileSync(pidFile, "starting", "utf-8");
@@ -1183,10 +1189,6 @@ export async function startGateway(
1183
1189
 
1184
1190
  applyIpcSocketDirOverride(gatewayEnv);
1185
1191
 
1186
- if (publicUrl) {
1187
- console.log(` HTTP URL: ${publicUrl}`);
1188
- }
1189
-
1190
1192
  let gateway;
1191
1193
 
1192
1194
  const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
@@ -1232,8 +1234,8 @@ export async function startGateway(
1232
1234
  const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
1233
1235
 
1234
1236
  // Wait for the gateway to be responsive before returning. Without this,
1235
- // callers (e.g. displayPairingQRCode) may try to connect before the HTTP
1236
- // 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.
1237
1239
  const start = Date.now();
1238
1240
  const timeoutMs = 30000;
1239
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.");
@@ -99,6 +99,23 @@ function tokenAuthHeader(token: string): Record<string, string> {
99
99
  const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
100
100
  const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
101
101
 
102
+ /**
103
+ * Drop the cached org ID for a given (token, platformUrl) pair. Used by the
104
+ * one-shot 401-retry path: a 401 on a session-token request frequently means
105
+ * the cached `Vellum-Organization-Id` header is stale (e.g. user switched
106
+ * orgs in another tab). Clearing the entry forces the next `authHeaders`
107
+ * call to refetch the org ID from the platform.
108
+ *
109
+ * Exported so other modules (e.g. local-runtime-client) can implement the
110
+ * same retry pattern without needing direct access to the cache map.
111
+ */
112
+ export function invalidateOrgIdCache(
113
+ token: string,
114
+ platformUrl?: string,
115
+ ): void {
116
+ orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
117
+ }
118
+
102
119
  /**
103
120
  * Returns the full set of headers needed for an authenticated platform
104
121
  * API request:
@@ -468,6 +485,7 @@ export async function hatchAssistant(
468
485
  method: "POST",
469
486
  headers: await authHeaders(token, platformUrl),
470
487
  body: JSON.stringify({}),
488
+ signal: AbortSignal.timeout(300_000),
471
489
  });
472
490
 
473
491
  if (response.ok) {
@@ -805,6 +823,45 @@ export function parseUnifiedJobStatus(
805
823
  };
806
824
  }
807
825
 
826
+ export interface BundleCompatibility {
827
+ min_runtime_version: string;
828
+ max_runtime_version: string | null;
829
+ }
830
+
831
+ /**
832
+ * Thrown by platformRequestSignedUrl when the platform rejects a download
833
+ * signed-URL request because the target runtime version is outside the
834
+ * ExportJob's [min_runtime_version, max_runtime_version] band. Terminal
835
+ * — callers must NOT retry; surface to the user and abort the
836
+ * teleport/restore wizard.
837
+ */
838
+ export class VersionMismatchError extends Error {
839
+ readonly bundleCompat: BundleCompatibility;
840
+ readonly targetRuntimeVersion: string;
841
+
842
+ constructor(bundleCompat: BundleCompatibility, targetRuntimeVersion: string) {
843
+ super(
844
+ VersionMismatchError.formatMessage(bundleCompat, targetRuntimeVersion),
845
+ );
846
+ this.name = "VersionMismatchError";
847
+ this.bundleCompat = bundleCompat;
848
+ this.targetRuntimeVersion = targetRuntimeVersion;
849
+ }
850
+
851
+ static formatMessage(
852
+ compat: BundleCompatibility,
853
+ targetRuntimeVersion: string,
854
+ ): string {
855
+ const range = compat.max_runtime_version
856
+ ? `${compat.min_runtime_version}–${compat.max_runtime_version}`
857
+ : `${compat.min_runtime_version}+`;
858
+ return (
859
+ `Cannot import: bundle requires runtime ${range}, but this runtime is ${targetRuntimeVersion}. ` +
860
+ `Update your runtime before importing.`
861
+ );
862
+ }
863
+ }
864
+
808
865
  /**
809
866
  * Request a signed URL from the platform for either uploading a new bundle
810
867
  * or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
@@ -816,6 +873,9 @@ export function parseUnifiedJobStatus(
816
873
  *
817
874
  * Retries once with a fresh org-ID cache on 401 to match the retry pattern
818
875
  * used by other authenticated platform helpers.
876
+ *
877
+ * Throws {@link VersionMismatchError} on a 422 `version_mismatch` response,
878
+ * which is terminal — callers must NOT retry.
819
879
  */
820
880
  export async function platformRequestSignedUrl(
821
881
  params: {
@@ -823,6 +883,11 @@ export async function platformRequestSignedUrl(
823
883
  bundleKey?: string;
824
884
  contentType?: string;
825
885
  contentLength?: number;
886
+ // Source-side, upload only: runtime version that produced the bundle.
887
+ minRuntimeVersion?: string;
888
+ maxRuntimeVersion?: string | null;
889
+ // Target-side, download only: runtime version that will import.
890
+ targetRuntimeVersion?: string;
826
891
  },
827
892
  token: string,
828
893
  platformUrl?: string,
@@ -839,6 +904,17 @@ export async function platformRequestSignedUrl(
839
904
  if (params.contentLength !== undefined) {
840
905
  body.content_length = params.contentLength;
841
906
  }
907
+ if (params.minRuntimeVersion !== undefined) {
908
+ body.min_runtime_version = params.minRuntimeVersion;
909
+ }
910
+ if (params.maxRuntimeVersion !== undefined) {
911
+ // Explicit null is the documented "no upper bound" sentinel; keep it
912
+ // in the payload rather than stripping to undefined.
913
+ body.max_runtime_version = params.maxRuntimeVersion;
914
+ }
915
+ if (params.targetRuntimeVersion !== undefined) {
916
+ body.target_runtime_version = params.targetRuntimeVersion;
917
+ }
842
918
 
843
919
  const doRequest = async (): Promise<Response> =>
844
920
  fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
@@ -854,7 +930,7 @@ export async function platformRequestSignedUrl(
854
930
  // lookup. For session-token callers, a 401 frequently means the
855
931
  // cached org ID is stale — calling doRequest() again without clearing
856
932
  // the cache would just send the same stale header and fail again.
857
- orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
933
+ invalidateOrgIdCache(token, platformUrl);
858
934
  response = await doRequest();
859
935
  }
860
936
 
@@ -873,9 +949,28 @@ export async function platformRequestSignedUrl(
873
949
  };
874
950
  }
875
951
 
952
+ // Non-success body. Read once and reuse for both the 422 version-mismatch
953
+ // branch and the generic-error fallthrough — `response.json()` consumes
954
+ // the body, so a second read would always return undefined.
876
955
  const errorBody = (await response.json().catch(() => ({}))) as {
877
956
  detail?: string;
957
+ reason?: string;
958
+ bundle_compat?: BundleCompatibility;
959
+ target_runtime_version?: string;
878
960
  };
961
+
962
+ if (
963
+ response.status === 422 &&
964
+ errorBody.reason === "version_mismatch" &&
965
+ errorBody.bundle_compat &&
966
+ typeof errorBody.target_runtime_version === "string"
967
+ ) {
968
+ throw new VersionMismatchError(
969
+ errorBody.bundle_compat,
970
+ errorBody.target_runtime_version,
971
+ );
972
+ }
973
+
879
974
  throw new Error(
880
975
  errorBody.detail ??
881
976
  `Failed to request signed URL: ${response.status} ${response.statusText}`,
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "child_process";
2
+ import { homedir } from "os";
2
3
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
3
4
  import { basename, dirname, join } from "path";
4
5
 
5
6
  import {
6
- getBaseDir,
7
7
  getDaemonPidPath,
8
8
  loadAllAssistants,
9
9
  } from "./assistant-config.js";
@@ -77,7 +77,7 @@ export async function retireLocal(
77
77
  // For named instances (instanceDir differs from the base directory),
78
78
  // archive and remove the entire instance directory. For the default
79
79
  // instance, archive only the .vellum subdirectory.
80
- const isNamedInstance = resources.instanceDir !== getBaseDir();
80
+ const isNamedInstance = resources.instanceDir !== homedir();
81
81
  const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
82
82
 
83
83
  // Move the data directory out of the way so the path is immediately available
@@ -28,3 +28,25 @@ export function resolveRuntimeMigrationUrl(
28
28
  }
29
29
  return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
30
30
  }
31
+
32
+ /**
33
+ * Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
34
+ * taking the assistant's topology into account.
35
+ *
36
+ * - For local/docker assistants, `runtimeUrl` is the loopback gateway and
37
+ * the runtime serves `/v1/<subpath>` directly.
38
+ * - For platform-managed (cloud="vellum") assistants the path is rewritten
39
+ * to the wildcard runtime proxy:
40
+ * `{platformUrl}/v1/assistants/<assistantId>/<subpath>`.
41
+ *
42
+ * The `subpath` is appended verbatim (e.g. `"identity"`).
43
+ */
44
+ export function resolveRuntimeUrl(
45
+ entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
46
+ subpath: string,
47
+ ): string {
48
+ if (entry.cloud === "vellum") {
49
+ return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/${subpath}`;
50
+ }
51
+ return `${entry.runtimeUrl}/v1/${subpath}`;
52
+ }