@vellumai/cli 0.7.1 → 0.7.3

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 (39) hide show
  1. package/AGENTS.md +3 -11
  2. package/bun.lock +0 -15
  3. package/package.json +1 -6
  4. package/src/__tests__/backup.test.ts +121 -5
  5. package/src/__tests__/teleport.test.ts +515 -10
  6. package/src/commands/backup.ts +35 -2
  7. package/src/commands/client.ts +90 -7
  8. package/src/commands/exec.ts +13 -4
  9. package/src/commands/hatch.ts +1 -1
  10. package/src/commands/login.ts +11 -0
  11. package/src/commands/restore.ts +7 -1
  12. package/src/commands/rollback.ts +1 -1
  13. package/src/commands/setup.ts +38 -73
  14. package/src/commands/teleport.ts +122 -12
  15. package/src/commands/upgrade.ts +8 -2
  16. package/src/commands/wake.ts +5 -16
  17. package/src/components/DefaultMainScreen.tsx +42 -130
  18. package/src/index.ts +1 -7
  19. package/src/lib/__tests__/docker.test.ts +53 -35
  20. package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
  22. package/src/lib/__tests__/runtime-url.test.ts +39 -1
  23. package/src/lib/assistant-client.ts +13 -5
  24. package/src/lib/assistant-config.ts +0 -25
  25. package/src/lib/backup-ops.ts +43 -17
  26. package/src/lib/client-identity.ts +9 -5
  27. package/src/lib/docker.ts +6 -267
  28. package/src/lib/environments/paths.ts +20 -0
  29. package/src/lib/guardian-token.ts +56 -6
  30. package/src/lib/hatch-local.ts +3 -26
  31. package/src/lib/local-runtime-client.ts +82 -1
  32. package/src/lib/local.ts +9 -7
  33. package/src/lib/ngrok.ts +36 -26
  34. package/src/lib/platform-client.ts +100 -1
  35. package/src/lib/retire-local.ts +2 -2
  36. package/src/lib/runtime-url.ts +22 -0
  37. package/src/lib/statefulset.ts +375 -0
  38. package/src/lib/upgrade-lifecycle.ts +97 -1
  39. package/src/commands/pair.ts +0 -212
@@ -12,8 +12,19 @@ import {
12
12
  } from "../lib/constants";
13
13
  import { loadGuardianToken } from "../lib/guardian-token";
14
14
  import { getLocalLanIPv4 } from "../lib/local";
15
+ import {
16
+ CLI_INTERFACE_ID,
17
+ getClientRegistrationHeaders,
18
+ } from "../lib/client-identity";
19
+ import {
20
+ fetchOrganizationId,
21
+ readPlatformToken,
22
+ } from "../lib/platform-client";
15
23
  import { tuiLog } from "../lib/tui-log";
16
24
 
25
+ const SUPPORTED_INTERFACES = ["cli"] as const;
26
+ type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
27
+
17
28
  const ANSI = {
18
29
  reset: "\x1b[0m",
19
30
  bold: "\x1b[1m",
@@ -26,7 +37,14 @@ interface ParsedArgs {
26
37
  runtimeUrl: string;
27
38
  assistantId: string;
28
39
  species: Species;
40
+ /** "vellum" for platform-hosted assistants, undefined for local. */
41
+ cloud?: string;
42
+ /** Platform session token (X-Session-Token), set when cloud === "vellum". */
43
+ platformToken?: string;
44
+ /** Guardian JWT (Authorization: Bearer), set for local assistants. */
29
45
  bearerToken?: string;
46
+ /** Interface identifier sent as X-Vellum-Interface-Id on all requests. */
47
+ interfaceId: SupportedInterface;
30
48
  project?: string;
31
49
  zone?: string;
32
50
  }
@@ -45,7 +63,9 @@ function parseArgs(): ParsedArgs {
45
63
  (arg === "--url" ||
46
64
  arg === "-u" ||
47
65
  arg === "--assistant-id" ||
48
- arg === "-a") &&
66
+ arg === "-a" ||
67
+ arg === "--interface" ||
68
+ arg === "-i") &&
49
69
  args[i + 1]
50
70
  ) {
51
71
  flagArgs.push(arg, args[++i]);
@@ -89,10 +109,19 @@ function parseArgs(): ParsedArgs {
89
109
 
90
110
  let runtimeUrl = entry?.localUrl || entry?.runtimeUrl || FALLBACK_RUNTIME_URL;
91
111
  let assistantId = entry?.assistantId || DAEMON_INTERNAL_ASSISTANT_ID;
92
- const bearerToken =
93
- loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined;
112
+ const cloud = entry?.cloud;
94
113
  const species: Species = (entry?.species as Species) ?? "vellum";
95
114
 
115
+ // Platform-hosted assistants use a session token; local assistants use a guardian JWT.
116
+ const platformToken =
117
+ cloud === "vellum" ? (readPlatformToken() ?? undefined) : undefined;
118
+ const bearerToken =
119
+ cloud === "vellum"
120
+ ? undefined
121
+ : (loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined);
122
+
123
+ let interfaceId: SupportedInterface = CLI_INTERFACE_ID;
124
+
96
125
  for (let i = 0; i < flagArgs.length; i++) {
97
126
  const flag = flagArgs[i];
98
127
  if ((flag === "--url" || flag === "-u") && flagArgs[i + 1]) {
@@ -102,6 +131,21 @@ function parseArgs(): ParsedArgs {
102
131
  flagArgs[i + 1]
103
132
  ) {
104
133
  assistantId = flagArgs[++i];
134
+ } else if ((flag === "--interface" || flag === "-i") && flagArgs[i + 1]) {
135
+ const value = flagArgs[++i];
136
+ if (value === "web") {
137
+ console.error(
138
+ `--interface web is not yet supported. Coming soon.`,
139
+ );
140
+ process.exit(1);
141
+ }
142
+ if (!(SUPPORTED_INTERFACES as readonly string[]).includes(value)) {
143
+ console.error(
144
+ `Unknown interface '${value}'. Supported: ${SUPPORTED_INTERFACES.join(", ")}.`,
145
+ );
146
+ process.exit(1);
147
+ }
148
+ interfaceId = value as SupportedInterface;
105
149
  }
106
150
  }
107
151
 
@@ -109,7 +153,10 @@ function parseArgs(): ParsedArgs {
109
153
  runtimeUrl: maybeSwapToLocalhost(runtimeUrl.replace(/\/+$/, "")),
110
154
  assistantId,
111
155
  species,
156
+ cloud,
157
+ platformToken,
112
158
  bearerToken,
159
+ interfaceId,
113
160
  project: entry?.project,
114
161
  zone: entry?.zone,
115
162
  };
@@ -166,6 +213,7 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
166
213
  ${ANSI.bold}OPTIONS:${ANSI.reset}
167
214
  -u, --url <url> Runtime URL
168
215
  -a, --assistant-id <id> Assistant ID
216
+ -i, --interface <id> Interface identifier (default: cli)
169
217
  -h, --help Show this help message
170
218
 
171
219
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -181,11 +229,46 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
181
229
  }
182
230
 
183
231
  export async function client(): Promise<void> {
184
- const { runtimeUrl, assistantId, species, bearerToken, project, zone } =
185
- parseArgs();
232
+ const {
233
+ runtimeUrl,
234
+ assistantId,
235
+ species,
236
+ cloud,
237
+ platformToken,
238
+ bearerToken,
239
+ interfaceId,
240
+ project,
241
+ zone,
242
+ } = parseArgs();
186
243
 
187
244
  tuiLog.init();
188
- tuiLog.info("session start", { runtimeUrl, assistantId, species });
245
+ tuiLog.info("session start", {
246
+ runtimeUrl,
247
+ assistantId,
248
+ species,
249
+ cloud,
250
+ interfaceId,
251
+ });
252
+
253
+ // Build pre-constructed request headers merged from auth + client registration.
254
+ // Spreading into every fetch site ensures consistency across REST and SSE endpoints.
255
+ let auth: Record<string, string> | undefined;
256
+ if (cloud === "vellum" && platformToken) {
257
+ const orgId = await fetchOrganizationId(platformToken).catch((err) => {
258
+ tuiLog.warn("failed to fetch organization id", { err: String(err) });
259
+ return undefined;
260
+ });
261
+ auth = {
262
+ "X-Session-Token": platformToken,
263
+ ...(orgId ? { "Vellum-Organization-Id": orgId } : {}),
264
+ ...getClientRegistrationHeaders(interfaceId),
265
+ };
266
+ } else {
267
+ auth = {
268
+ ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
269
+ ...getClientRegistrationHeaders(interfaceId),
270
+ };
271
+ }
189
272
 
190
273
  const { renderChatApp } = await import("../components/DefaultMainScreen");
191
274
 
@@ -203,6 +286,6 @@ export async function client(): Promise<void> {
203
286
  console.log(`${ANSI.dim}Disconnected.${ANSI.reset}`);
204
287
  process.exit(0);
205
288
  },
206
- { bearerToken, project, zone },
289
+ { auth, project, zone },
207
290
  );
208
291
  }
@@ -156,10 +156,19 @@ export async function exec(): Promise<void> {
156
156
  const cloud = resolveCloud(entry);
157
157
 
158
158
  if (cloud === "local") {
159
- console.error(
160
- "Cannot exec into a local assistant — it runs directly on this machine.",
161
- );
162
- process.exit(1);
159
+ const child = spawn(command[0], command.slice(1), { stdio: "inherit" });
160
+ await new Promise<void>((resolve) => {
161
+ child.on("close", (code) => {
162
+ process.exitCode = code ?? 0;
163
+ resolve();
164
+ });
165
+ child.on("error", (err) => {
166
+ console.error(`Error: ${err.message}`);
167
+ process.exitCode = 1;
168
+ resolve();
169
+ });
170
+ });
171
+ return;
163
172
  }
164
173
 
165
174
  if (cloud === "apple-container") {
@@ -32,7 +32,7 @@ export type { PollResult, WatchHatchingResult } from "../lib/gcp";
32
32
  const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";
33
33
 
34
34
  const HATCH_TIMEOUT_MS: Record<Species, number> = {
35
- vellum: 2 * 60 * 1000,
35
+ vellum: 5 * 60 * 1000,
36
36
  openclaw: 10 * 60 * 1000,
37
37
  };
38
38
  const DEFAULT_SPECIES: Species = "vellum";
@@ -10,6 +10,10 @@ import {
10
10
  setActiveAssistant,
11
11
  } from "../lib/assistant-config";
12
12
  import { computeDeviceId } from "../lib/guardian-token";
13
+ import {
14
+ fetchAssistantIngressUrl,
15
+ fetchCurrentVersion,
16
+ } from "../lib/upgrade-lifecycle.js";
13
17
  import {
14
18
  clearPlatformToken,
15
19
  ensureSelfHostedLocalRegistration,
@@ -210,12 +214,19 @@ export async function login(): Promise<void> {
210
214
  if (entry && entry.cloud !== "vellum") {
211
215
  const orgId = await fetchOrganizationId(token);
212
216
  const clientInstallationId = computeDeviceId();
217
+ const [assistantVersion, ingressUrl] = await Promise.all([
218
+ fetchCurrentVersion(entry.runtimeUrl),
219
+ fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
220
+ ]);
213
221
  const registration = await ensureSelfHostedLocalRegistration(
214
222
  token,
215
223
  orgId,
216
224
  clientInstallationId,
217
225
  entry.assistantId,
218
226
  "cli",
227
+ assistantVersion,
228
+ getPlatformUrl(),
229
+ ingressUrl,
219
230
  );
220
231
  console.log(
221
232
  `Registered assistant: ${registration.assistant.name} (${registration.assistant.id})`,
@@ -179,7 +179,13 @@ async function restorePlatform(
179
179
  process.exit(1);
180
180
  }
181
181
 
182
- // Step 1.5 — Upload to GCS via signed URL
182
+ // Step 1.5 — Upload to GCS via signed URL.
183
+ // We deliberately omit min/max runtime version here: restore uploads an
184
+ // arbitrary .vbundle from disk (often produced by a different runtime
185
+ // than the one we'd query right now), and the bundle's own manifest is
186
+ // the authority on its compatibility band. The platform skips the
187
+ // version gate when these fields are absent and re-derives compat from
188
+ // the manifest when it processes the import.
183
189
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
184
190
  { operation: "upload" },
185
191
  token,
@@ -340,7 +340,7 @@ export async function rollback(): Promise<void> {
340
340
  const signingKey =
341
341
  capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex");
342
342
 
343
- // Build extra env vars, excluding keys managed by serviceDockerRunArgs
343
+ // Build extra env vars, excluding keys managed by buildServiceRunArgs
344
344
  const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
345
345
  for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
346
346
  if (process.env[envVar]) {
@@ -1,43 +1,6 @@
1
1
  import { createInterface } from "readline";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { homedir } from "os";
4
- import { dirname, join } from "path";
5
2
 
6
- function getVellumDir(): string {
7
- const base = process.env.BASE_DATA_DIR?.trim() || homedir();
8
- return join(base, ".vellum");
9
- }
10
-
11
- function getEnvFilePath(): string {
12
- return join(getVellumDir(), ".env");
13
- }
14
-
15
- function readEnvFile(): Record<string, string> {
16
- const envPath = getEnvFilePath();
17
- const vars: Record<string, string> = {};
18
- if (!existsSync(envPath)) return vars;
19
-
20
- const content = readFileSync(envPath, "utf-8");
21
- for (const line of content.split("\n")) {
22
- const trimmed = line.trim();
23
- if (!trimmed || trimmed.startsWith("#")) continue;
24
- const eqIdx = trimmed.indexOf("=");
25
- if (eqIdx === -1) continue;
26
- const key = trimmed.slice(0, eqIdx).trim();
27
- const value = trimmed.slice(eqIdx + 1).trim();
28
- vars[key] = value;
29
- }
30
- return vars;
31
- }
32
-
33
- function writeEnvFile(vars: Record<string, string>): void {
34
- const envPath = getEnvFilePath();
35
- const dir = dirname(envPath);
36
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
-
38
- const lines = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
39
- writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
40
- }
3
+ import { resolveAssistant } from "../lib/assistant-config.js";
41
4
 
42
5
  async function promptMasked(prompt: string): Promise<string> {
43
6
  return new Promise((resolve) => {
@@ -46,7 +9,6 @@ async function promptMasked(prompt: string): Promise<string> {
46
9
  output: process.stdout,
47
10
  });
48
11
 
49
- // Disable echoing by writing the prompt manually and intercepting keystrokes
50
12
  process.stdout.write(prompt);
51
13
 
52
14
  const stdin = process.stdin;
@@ -60,7 +22,6 @@ async function promptMasked(prompt: string): Promise<string> {
60
22
  const char = key.toString("utf-8");
61
23
 
62
24
  if (char === "\r" || char === "\n") {
63
- // Enter pressed
64
25
  stdin.removeListener("data", onData);
65
26
  if (stdin.isTTY) {
66
27
  stdin.setRawMode(wasRaw ?? false);
@@ -69,11 +30,9 @@ async function promptMasked(prompt: string): Promise<string> {
69
30
  rl.close();
70
31
  resolve(input);
71
32
  } else if (char === "\u0003") {
72
- // Ctrl+C
73
33
  process.stdout.write("\n");
74
34
  process.exit(1);
75
35
  } else if (char === "\u007F" || char === "\b") {
76
- // Backspace
77
36
  if (input.length > 0) {
78
37
  input = input.slice(0, -1);
79
38
  process.stdout.write("\b \b");
@@ -111,39 +70,23 @@ export async function setup(): Promise<void> {
111
70
  console.log("");
112
71
  console.log("Interactive wizard to configure API keys.");
113
72
  console.log(
114
- "Keys are validated against their APIs and saved to <BASE_DATA_DIR>/.vellum/.env.",
73
+ "Injects secrets into your running assistant via the gateway API.",
115
74
  );
116
75
  process.exit(0);
117
76
  }
118
77
 
119
- console.log("Vellum Setup");
120
- console.log("============\n");
121
-
122
- const existingVars = readEnvFile();
123
- const hasExistingKey = !!existingVars.ANTHROPIC_API_KEY;
78
+ const entry = resolveAssistant();
79
+ if (!entry) {
80
+ console.error(
81
+ "Error: No active assistant found. Run `vellum hatch` first.",
82
+ );
83
+ process.exit(1);
84
+ }
124
85
 
125
- if (hasExistingKey) {
126
- const masked =
127
- existingVars.ANTHROPIC_API_KEY.slice(0, 7) +
128
- "..." +
129
- existingVars.ANTHROPIC_API_KEY.slice(-4);
130
- console.log(`Anthropic API key is already configured (${masked}).`);
86
+ const gatewayUrl = entry.localUrl ?? entry.runtimeUrl;
131
87
 
132
- const rl = createInterface({
133
- input: process.stdin,
134
- output: process.stdout,
135
- });
136
- const answer = await new Promise<string>((resolve) => {
137
- rl.question("Overwrite? [y/N] ", resolve);
138
- });
139
- rl.close();
140
-
141
- if (answer.trim().toLowerCase() !== "y") {
142
- console.log("\nSetup complete. No changes made.");
143
- return;
144
- }
145
- console.log("");
146
- }
88
+ console.log("Vellum Setup");
89
+ console.log("============\n");
147
90
 
148
91
  const apiKey = await promptMasked(
149
92
  "Enter your Anthropic API key (sk-ant-...): ",
@@ -164,9 +107,31 @@ export async function setup(): Promise<void> {
164
107
  process.exit(1);
165
108
  }
166
109
 
167
- existingVars.ANTHROPIC_API_KEY = apiKey.trim();
168
- writeEnvFile(existingVars);
110
+ const headers: Record<string, string> = {
111
+ "Content-Type": "application/json",
112
+ Accept: "application/json",
113
+ };
114
+ if (entry.bearerToken) {
115
+ headers["Authorization"] = `Bearer ${entry.bearerToken}`;
116
+ }
117
+
118
+ const response = await fetch(`${gatewayUrl}/v1/secrets`, {
119
+ method: "POST",
120
+ headers,
121
+ body: JSON.stringify({
122
+ type: "credential",
123
+ name: "ANTHROPIC_API_KEY",
124
+ value: apiKey.trim(),
125
+ }),
126
+ signal: AbortSignal.timeout(10_000),
127
+ });
128
+
129
+ if (!response.ok) {
130
+ console.error(
131
+ `Error: Failed to store API key in assistant (${response.status}).`,
132
+ );
133
+ process.exit(1);
134
+ }
169
135
 
170
- console.log(`\nAPI key saved to ${getEnvFilePath()}`);
171
- console.log("Setup complete.");
136
+ console.log("\nAPI key saved to assistant. Setup complete.");
172
137
  }
@@ -21,6 +21,7 @@ import {
21
21
  platformImportBundleFromGcs,
22
22
  platformImportPreflightFromGcs,
23
23
  platformRequestSignedUrl,
24
+ VersionMismatchError,
24
25
  ensureSelfHostedLocalRegistration,
25
26
  readGatewayCredential,
26
27
  reprovisionAssistantApiKey,
@@ -30,6 +31,7 @@ import {
30
31
  } from "../lib/platform-client.js";
31
32
  import {
32
33
  localRuntimeExportToGcs,
34
+ localRuntimeIdentity,
33
35
  localRuntimeImportFromGcs,
34
36
  localRuntimePollJobStatus,
35
37
  MigrationInProgressError,
@@ -45,7 +47,10 @@ import { hatchLocal } from "../lib/hatch-local.js";
45
47
  import { retireLocal } from "../lib/retire-local.js";
46
48
  import { validateAssistantName } from "../lib/retire-archive.js";
47
49
  import { stopProcessByPidFile } from "../lib/process.js";
48
- import { fetchCurrentVersion } from "../lib/upgrade-lifecycle.js";
50
+ import {
51
+ fetchAssistantIngressUrl,
52
+ fetchCurrentVersion,
53
+ } from "../lib/upgrade-lifecycle.js";
49
54
  import { compareVersions } from "../lib/version-compat.js";
50
55
  import { join } from "node:path";
51
56
 
@@ -254,13 +259,17 @@ async function getAccessToken(
254
259
 
255
260
  /**
256
261
  * Detect a 401 Unauthorized raised by `localRuntimeExportToGcs` /
257
- * `localRuntimeImportFromGcs`. Both throw Error with a message of the form
258
- * `"Local runtime <op> failed (401): ..."` when the gateway rejects the
259
- * cached guardian token.
262
+ * `localRuntimeImportFromGcs` / `localRuntimeIdentity`. They throw Error
263
+ * with a message of the form `"Local runtime <op> failed (401): ..."` or
264
+ * `"Failed to fetch runtime identity: 401 ..."` when the gateway rejects
265
+ * the cached guardian token.
260
266
  */
261
267
  function isRuntime401(err: unknown): boolean {
262
268
  const msg = err instanceof Error ? err.message : String(err);
263
- return /Local runtime [^(]*failed \(401\)/.test(msg);
269
+ return (
270
+ /Local runtime [^(]*failed \(401\)/.test(msg) ||
271
+ /Failed to fetch runtime identity: 401\b/.test(msg)
272
+ );
264
273
  }
265
274
 
266
275
  /**
@@ -367,13 +376,40 @@ async function exportFromAssistant(
367
376
  }
368
377
 
369
378
  if (cloud === "local" || cloud === "docker") {
379
+ // Ask the source runtime which version it's running before requesting
380
+ // the signed upload URL. The bundle is produced by the daemon (not the
381
+ // CLI), so the daemon's version is what defines the bundle's
382
+ // `min_runtime_version`. Stamping with `cliPkg.version` instead would
383
+ // record an inaccurate compatibility band whenever the CLI/daemon have
384
+ // drifted (a normal case in real usage — `vellum upgrade` swaps the
385
+ // daemon, the CLI is updated separately).
386
+ let sourceRuntimeVersion: string;
387
+ try {
388
+ const identity = await callRuntimeWithAuthRetry(
389
+ entry.runtimeUrl,
390
+ entry.assistantId,
391
+ async (token) => localRuntimeIdentity(entry, token),
392
+ );
393
+ sourceRuntimeVersion = identity.version;
394
+ } catch (err) {
395
+ const msg = err instanceof Error ? err.message : String(err);
396
+ console.error(
397
+ `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
398
+ );
399
+ process.exit(1);
400
+ }
401
+
370
402
  // Request a signed upload URL from the platform instance that will
371
403
  // eventually own the bundle (i.e. the one the importer will read from).
372
404
  // Passing the target's runtime URL here keeps upload and download on
373
405
  // the same platform — otherwise a non-default/stale platform URL would
374
406
  // cause the import to look at an empty object.
375
407
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
376
- { operation: "upload" },
408
+ {
409
+ operation: "upload",
410
+ minRuntimeVersion: sourceRuntimeVersion,
411
+ maxRuntimeVersion: null,
412
+ },
377
413
  platformToken,
378
414
  bundlePlatformUrl,
379
415
  );
@@ -440,6 +476,24 @@ async function exportFromAssistant(
440
476
  }
441
477
 
442
478
  if (cloud === "vellum") {
479
+ // Ask the managed runtime which version it's running so the signed-URL
480
+ // request records the bundle's actual `min_runtime_version`. The
481
+ // platform-managed runtime is the exporter; the CLI version is
482
+ // unrelated. Routed via the wildcard proxy with platform-token auth
483
+ // (resolveRuntimeUrl + migrationRequestHeaders inside
484
+ // localRuntimeIdentity).
485
+ let sourceRuntimeVersion: string;
486
+ try {
487
+ const identity = await localRuntimeIdentity(entry, platformToken);
488
+ sourceRuntimeVersion = identity.version;
489
+ } catch (err) {
490
+ const msg = err instanceof Error ? err.message : String(err);
491
+ console.error(
492
+ `Error: Could not fetch runtime identity from '${entry.assistantId}': ${msg}`,
493
+ );
494
+ process.exit(1);
495
+ }
496
+
443
497
  // Platform source — request a signed upload URL on the same platform
444
498
  // instance the bundle will eventually be imported from, then ask the
445
499
  // managed runtime to export directly to GCS. The runtime endpoint is
@@ -449,7 +503,11 @@ async function exportFromAssistant(
449
503
  // pick that shape for `cloud === "vellum"` and `migrationRequestHeaders`
450
504
  // to send platform-token auth (no guardian-token bootstrap).
451
505
  const { url: uploadUrl, bundleKey } = await platformRequestSignedUrl(
452
- { operation: "upload" },
506
+ {
507
+ operation: "upload",
508
+ minRuntimeVersion: sourceRuntimeVersion,
509
+ maxRuntimeVersion: null,
510
+ },
453
511
  platformToken,
454
512
  bundlePlatformUrl,
455
513
  );
@@ -659,11 +717,56 @@ async function importToAssistant(
659
717
  // never touches the bytes. The URL must target the same platform the
660
718
  // bundle was uploaded to; otherwise the object won't exist on this
661
719
  // platform's GCS bucket.
662
- const { url: bundleUrl } = await platformRequestSignedUrl(
663
- { operation: "download", bundleKey },
664
- platformToken,
665
- bundlePlatformUrl,
666
- );
720
+ //
721
+ // The platform's vbundle version gate compares the **target runtime's**
722
+ // version against the bundle's compatibility range. The CLI and the
723
+ // target assistant's daemon can diverge (assistants upgrade
724
+ // independently), so we MUST query the target runtime's `/v1/identity`
725
+ // for its version rather than sending `cliPkg.version`. Sending the CLI
726
+ // version here would falsely 422 a valid import (or pass a bundle the
727
+ // target can't actually load) whenever the two drift apart.
728
+ let targetRuntimeVersion: string;
729
+ try {
730
+ const identity = await callRuntimeWithAuthRetry(
731
+ entry.runtimeUrl,
732
+ entry.assistantId,
733
+ (token) => localRuntimeIdentity(entry, token),
734
+ );
735
+ targetRuntimeVersion = identity.version;
736
+ } catch (err) {
737
+ // Surface and abort — silently falling back to `cliPkg.version` would
738
+ // re-introduce the bug this code is fixing. If the runtime is
739
+ // unreachable, the import would fail downstream anyway.
740
+ const msg = err instanceof Error ? err.message : String(err);
741
+ console.error(
742
+ `Error: Could not read target runtime version from '${entry.assistantId}': ${msg}`,
743
+ );
744
+ console.error(`Try: vellum wake ${entry.assistantId}`);
745
+ process.exit(1);
746
+ }
747
+
748
+ let bundleUrl: string;
749
+ try {
750
+ const result = await platformRequestSignedUrl(
751
+ {
752
+ operation: "download",
753
+ bundleKey,
754
+ targetRuntimeVersion,
755
+ },
756
+ platformToken,
757
+ bundlePlatformUrl,
758
+ );
759
+ bundleUrl = result.url;
760
+ } catch (err) {
761
+ if (err instanceof VersionMismatchError) {
762
+ // 422 version_mismatch is terminal — the bundle's runtime range and
763
+ // the target runtime's version don't overlap. Surface the
764
+ // platform-formatted message and exit; do NOT retry.
765
+ console.error(`Error: ${err.message}`);
766
+ process.exit(1);
767
+ }
768
+ throw err;
769
+ }
667
770
 
668
771
  console.log("Importing data...");
669
772
 
@@ -966,12 +1069,19 @@ async function tryInjectPlatformCredentials(
966
1069
  const user = await fetchCurrentUser(token);
967
1070
  const orgId = await fetchOrganizationId(token);
968
1071
  const clientInstallationId = computeDeviceId();
1072
+ const [assistantVersion, ingressUrl] = await Promise.all([
1073
+ fetchCurrentVersion(entry.runtimeUrl),
1074
+ fetchAssistantIngressUrl(entry.runtimeUrl, entry.bearerToken),
1075
+ ]);
969
1076
  const registration = await ensureSelfHostedLocalRegistration(
970
1077
  token,
971
1078
  orgId,
972
1079
  clientInstallationId,
973
1080
  entry.assistantId,
974
1081
  "cli",
1082
+ assistantVersion,
1083
+ getPlatformUrl(),
1084
+ ingressUrl,
975
1085
  );
976
1086
 
977
1087
  // Resolve the API key: 1) fresh from registration, 2) existing from
@@ -40,6 +40,7 @@ import {
40
40
  buildStartingEvent,
41
41
  buildUpgradeCommitMessage,
42
42
  captureContainerEnv,
43
+ captureUpgradeFailureLogs,
43
44
  commitWorkspaceViaGateway,
44
45
  CONTAINER_ENV_EXCLUDE_KEYS,
45
46
  rollbackMigrations,
@@ -428,9 +429,9 @@ async function upgradeDocker(
428
429
 
429
430
  // Build the set of extra env vars to replay on the new assistant container.
430
431
  // Captured env vars serve as the base; keys already managed by
431
- // serviceDockerRunArgs are excluded to avoid duplicates.
432
+ // buildServiceRunArgs are excluded to avoid duplicates.
432
433
  const envKeysSetByRunArgs = new Set(CONTAINER_ENV_EXCLUDE_KEYS);
433
- // Only exclude keys that serviceDockerRunArgs will actually set
434
+ // Only exclude keys that buildServiceRunArgs will actually set
434
435
  for (const envVar of ["ANTHROPIC_API_KEY", "VELLUM_PLATFORM_URL"]) {
435
436
  if (process.env[envVar]) {
436
437
  envKeysSetByRunArgs.add(envVar);
@@ -511,6 +512,11 @@ async function upgradeDocker(
511
512
  } else {
512
513
  console.error(`\n❌ Containers failed to become ready within the timeout.`);
513
514
 
515
+ const logDir = await captureUpgradeFailureLogs(res, `${instanceName}-upgrade-failure`);
516
+ if (logDir) {
517
+ console.log(`📋 Container logs saved to: ${logDir}`);
518
+ }
519
+
514
520
  if (previousImageRefs) {
515
521
  await broadcastUpgradeEvent(
516
522
  entry.runtimeUrl,
@@ -195,22 +195,11 @@ export async function wake(): Promise<void> {
195
195
  }
196
196
 
197
197
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
198
- // Scope BASE_DATA_DIR to the woken instance so ngrok reads the correct
199
- // instance config, then restore on any exit path.
200
- const prevBaseDataDir = process.env.BASE_DATA_DIR;
201
- process.env.BASE_DATA_DIR = resources.instanceDir;
202
- try {
203
- const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
204
- if (ngrokChild?.pid) {
205
- const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
206
- writeFileSync(ngrokPidFile, String(ngrokChild.pid));
207
- }
208
- } finally {
209
- if (prevBaseDataDir !== undefined) {
210
- process.env.BASE_DATA_DIR = prevBaseDataDir;
211
- } else {
212
- delete process.env.BASE_DATA_DIR;
213
- }
198
+ const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
199
+ const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort, workspaceDir);
200
+ if (ngrokChild?.pid) {
201
+ const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
202
+ writeFileSync(ngrokPidFile, String(ngrokChild.pid));
214
203
  }
215
204
 
216
205
  console.log("Wake complete.");