@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
@@ -0,0 +1,591 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Lockfile isolation (mirrors teleport.test.ts)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), "cli-backup-test-"));
20
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Mocks set up before importing the module under test
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import * as fs from "node:fs";
27
+
28
+ import * as assistantConfig from "../lib/assistant-config.js";
29
+ import * as backupOps from "../lib/backup-ops.js";
30
+ import * as guardianToken from "../lib/guardian-token.js";
31
+ import * as localRuntimeClient from "../lib/local-runtime-client.js";
32
+ import { MigrationInProgressError } from "../lib/local-runtime-client.js";
33
+ import * as platformClient from "../lib/platform-client.js";
34
+
35
+ const findAssistantByNameMock = spyOn(
36
+ assistantConfig,
37
+ "findAssistantByName",
38
+ ).mockReturnValue(null);
39
+
40
+ const readPlatformTokenMock = spyOn(
41
+ platformClient,
42
+ "readPlatformToken",
43
+ ).mockReturnValue("platform-token");
44
+
45
+ const getPlatformUrlMock = spyOn(
46
+ platformClient,
47
+ "getPlatformUrl",
48
+ ).mockReturnValue("https://platform.vellum.ai");
49
+
50
+ const platformRequestSignedUrlMock = spyOn(
51
+ platformClient,
52
+ "platformRequestSignedUrl",
53
+ ).mockImplementation(async (params) => ({
54
+ url:
55
+ params.operation === "upload"
56
+ ? "https://storage.googleapis.com/bucket/signed-upload"
57
+ : "https://storage.googleapis.com/bucket/signed-download",
58
+ bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
59
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
60
+ }));
61
+
62
+ const localRuntimeExportToGcsMock = spyOn(
63
+ localRuntimeClient,
64
+ "localRuntimeExportToGcs",
65
+ ).mockResolvedValue({ jobId: "platform-export-job-1" });
66
+
67
+ const localRuntimeIdentityMock = spyOn(
68
+ localRuntimeClient,
69
+ "localRuntimeIdentity",
70
+ ).mockResolvedValue({ version: "0.6.5" });
71
+
72
+ const localRuntimePollJobStatusMock = spyOn(
73
+ localRuntimeClient,
74
+ "localRuntimePollJobStatus",
75
+ ).mockResolvedValue({
76
+ jobId: "platform-export-job-1",
77
+ type: "export",
78
+ status: "complete",
79
+ result: { manifest_sha256: "abc123def456" },
80
+ });
81
+
82
+ // Mode 1 (runtime-direct local backup) uses guardian tokens. Don't exercise
83
+ // it here, but the spies need to exist so the module under test can import
84
+ // them without surprises.
85
+ spyOn(guardianToken, "loadGuardianToken").mockReturnValue({
86
+ accessToken: "local-token",
87
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
88
+ } as unknown as ReturnType<typeof guardianToken.loadGuardianToken>);
89
+ spyOn(guardianToken, "leaseGuardianToken").mockResolvedValue({
90
+ accessToken: "leased-token",
91
+ accessTokenExpiresAt: new Date(Date.now() + 60_000).toISOString(),
92
+ } as unknown as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>);
93
+
94
+ const getBackupsDirMock = spyOn(backupOps, "getBackupsDir").mockReturnValue(
95
+ "/tmp/backups-default",
96
+ );
97
+
98
+ const mkdirSyncMock = spyOn(fs, "mkdirSync").mockImplementation(
99
+ (() => undefined) as never,
100
+ );
101
+ const writeFileSyncMock = spyOn(fs, "writeFileSync").mockImplementation(
102
+ () => undefined,
103
+ );
104
+
105
+ let originalFetch: typeof globalThis.fetch;
106
+ let exitMock: ReturnType<typeof mock>;
107
+
108
+ const VELLUM_ENTRY = {
109
+ assistantId: "11111111-2222-3333-4444-555555555555",
110
+ runtimeUrl: "https://platform.vellum.ai",
111
+ cloud: "vellum",
112
+ species: "vellum",
113
+ hatchedAt: new Date().toISOString(),
114
+ } satisfies assistantConfig.AssistantEntry;
115
+
116
+ function setArgv(...rest: string[]) {
117
+ process.argv = ["bun", "vellum", "backup", ...rest];
118
+ }
119
+
120
+ beforeEach(() => {
121
+ originalFetch = globalThis.fetch;
122
+ exitMock = mock((code?: number) => {
123
+ throw new Error(`process.exit:${code}`);
124
+ });
125
+ process.exit = exitMock as unknown as typeof process.exit;
126
+
127
+ findAssistantByNameMock.mockReset();
128
+ findAssistantByNameMock.mockReturnValue(null);
129
+ readPlatformTokenMock.mockReset();
130
+ readPlatformTokenMock.mockReturnValue("platform-token");
131
+ getPlatformUrlMock.mockReset();
132
+ getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
133
+ platformRequestSignedUrlMock.mockReset();
134
+ platformRequestSignedUrlMock.mockImplementation(async (params) => ({
135
+ url:
136
+ params.operation === "upload"
137
+ ? "https://storage.googleapis.com/bucket/signed-upload"
138
+ : "https://storage.googleapis.com/bucket/signed-download",
139
+ bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
140
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
141
+ }));
142
+ localRuntimeExportToGcsMock.mockReset();
143
+ localRuntimeExportToGcsMock.mockResolvedValue({
144
+ jobId: "platform-export-job-1",
145
+ });
146
+ localRuntimeIdentityMock.mockReset();
147
+ localRuntimeIdentityMock.mockResolvedValue({ version: "0.6.5" });
148
+ localRuntimePollJobStatusMock.mockReset();
149
+ localRuntimePollJobStatusMock.mockResolvedValue({
150
+ jobId: "platform-export-job-1",
151
+ type: "export",
152
+ status: "complete",
153
+ result: { manifest_sha256: "abc123def456" },
154
+ });
155
+ getBackupsDirMock.mockReset();
156
+ getBackupsDirMock.mockReturnValue("/tmp/backups-default");
157
+ mkdirSyncMock.mockReset();
158
+ mkdirSyncMock.mockImplementation((() => undefined) as never);
159
+ writeFileSyncMock.mockReset();
160
+ writeFileSyncMock.mockImplementation(() => undefined);
161
+ });
162
+
163
+ afterEach(() => {
164
+ globalThis.fetch = originalFetch;
165
+ });
166
+
167
+ afterAll(() => {
168
+ // Restore module-level spies so they don't bleed into other test files
169
+ // when bun test runs the whole suite.
170
+ findAssistantByNameMock.mockRestore();
171
+ readPlatformTokenMock.mockRestore();
172
+ getPlatformUrlMock.mockRestore();
173
+ platformRequestSignedUrlMock.mockRestore();
174
+ localRuntimeExportToGcsMock.mockRestore();
175
+ localRuntimeIdentityMock.mockRestore();
176
+ localRuntimePollJobStatusMock.mockRestore();
177
+ getBackupsDirMock.mockRestore();
178
+ mkdirSyncMock.mockRestore();
179
+ writeFileSyncMock.mockRestore();
180
+ rmSync(testDir, { recursive: true, force: true });
181
+ });
182
+
183
+ import { backup } from "../commands/backup.js";
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Helper: simulated GCS download response
187
+ // ---------------------------------------------------------------------------
188
+ function mockGcsDownload(body: Uint8Array, ok = true, status = 200) {
189
+ globalThis.fetch = mock(async () => {
190
+ const responseBody: BodyInit = ok
191
+ ? new Blob([body as unknown as ArrayBuffer])
192
+ : "boom";
193
+ return new Response(responseBody, {
194
+ status,
195
+ statusText: ok ? "OK" : "Error",
196
+ });
197
+ }) as unknown as typeof globalThis.fetch;
198
+ }
199
+
200
+ describe("vellum backup <platform-managed>: GCS happy path", () => {
201
+ test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
202
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
203
+ setArgv("my-platform");
204
+
205
+ const bytes = new Uint8Array([1, 2, 3, 4]);
206
+ mockGcsDownload(bytes);
207
+
208
+ await backup();
209
+
210
+ // Upload-URL request to the platform.
211
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
212
+ expect.objectContaining({
213
+ operation: "upload",
214
+ minRuntimeVersion: "0.6.5",
215
+ maxRuntimeVersion: null,
216
+ }),
217
+ "platform-token",
218
+ "https://platform.vellum.ai",
219
+ );
220
+
221
+ // Runtime export-to-gcs kicked off via the entry-aware helper. URL
222
+ // construction is exercised in `local-runtime-client.test.ts`; here we
223
+ // assert the helper got the right entry + token + params.
224
+ expect(localRuntimeExportToGcsMock).toHaveBeenCalledWith(
225
+ expect.objectContaining({
226
+ cloud: "vellum",
227
+ runtimeUrl: "https://platform.vellum.ai",
228
+ assistantId: "11111111-2222-3333-4444-555555555555",
229
+ }),
230
+ "platform-token",
231
+ expect.objectContaining({
232
+ uploadUrl: "https://storage.googleapis.com/bucket/signed-upload",
233
+ description: "CLI backup",
234
+ }),
235
+ );
236
+
237
+ // Poll uses the entry-aware helper (wildcard URL, NOT the dedicated
238
+ // platform jobs/{id}/ endpoint).
239
+ expect(localRuntimePollJobStatusMock).toHaveBeenCalledWith(
240
+ expect.objectContaining({ cloud: "vellum" }),
241
+ "platform-token",
242
+ "platform-export-job-1",
243
+ );
244
+
245
+ // Download URL keyed off the upload's bundleKey. We deliberately do
246
+ // NOT send `targetRuntimeVersion` here — this backup downloads the
247
+ // bundle to disk for offline storage; there is no target runtime to
248
+ // gate against, and an older CLI must be able to download newer
249
+ // assistant backups.
250
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
251
+ expect.objectContaining({
252
+ operation: "download",
253
+ bundleKey: "uploads/org-1/bundle-abc.vbundle",
254
+ }),
255
+ "platform-token",
256
+ "https://platform.vellum.ai",
257
+ );
258
+ const downloadCall = platformRequestSignedUrlMock.mock.calls.find(
259
+ (c) => (c[0] as { operation: string }).operation === "download",
260
+ );
261
+ expect(downloadCall).toBeDefined();
262
+ expect(downloadCall![0]).not.toHaveProperty("targetRuntimeVersion");
263
+
264
+ // GCS fetch went directly to the signed download URL with no auth.
265
+ const gcsFetch = globalThis.fetch as unknown as ReturnType<typeof mock>;
266
+ expect(gcsFetch).toHaveBeenCalledWith(
267
+ "https://storage.googleapis.com/bucket/signed-download",
268
+ );
269
+
270
+ // File written to disk with the bytes from GCS.
271
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
272
+ const [outputPath, written] = writeFileSyncMock.mock.calls[0]!;
273
+ expect(written).toEqual(bytes);
274
+ expect(typeof outputPath).toBe("string");
275
+ expect(outputPath as string).toMatch(
276
+ /\/tmp\/backups-default\/my-platform-.*\.vbundle$/,
277
+ );
278
+ expect(mkdirSyncMock).toHaveBeenCalled();
279
+ });
280
+
281
+ test("--output override is respected", async () => {
282
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
283
+ setArgv("my-platform", "--output", "/custom/path/backup.vbundle");
284
+
285
+ mockGcsDownload(new Uint8Array([7, 7, 7]));
286
+
287
+ await backup();
288
+
289
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
290
+ expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
291
+ "/custom/path/backup.vbundle",
292
+ );
293
+ });
294
+
295
+ test("default output path is getBackupsDir() + name-timestamp.vbundle", async () => {
296
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
297
+ setArgv("my-platform");
298
+
299
+ mockGcsDownload(new Uint8Array([1]));
300
+
301
+ await backup();
302
+
303
+ const [outputPath] = writeFileSyncMock.mock.calls[0]!;
304
+ expect(outputPath as string).toMatch(
305
+ /^\/tmp\/backups-default\/my-platform-/,
306
+ );
307
+ expect(outputPath as string).toMatch(/\.vbundle$/);
308
+ });
309
+
310
+ test("signed-URL requests target entry.runtimeUrl, not getPlatformUrl() — regression for staging/dev assistants", async () => {
311
+ // Assistant lives on a non-default platform instance (e.g. staging).
312
+ // `getPlatformUrl()` still returns the default — picking it up for
313
+ // signed URLs would target the wrong GCS bucket.
314
+ const stagingEntry = {
315
+ ...VELLUM_ENTRY,
316
+ runtimeUrl: "https://staging-platform.vellum.ai",
317
+ };
318
+ findAssistantByNameMock.mockReturnValue(stagingEntry);
319
+ getPlatformUrlMock.mockReturnValue("https://platform.vellum.ai");
320
+ setArgv("my-platform");
321
+
322
+ mockGcsDownload(new Uint8Array([9]));
323
+
324
+ await backup();
325
+
326
+ // Both upload and download URL requests are pinned to the entry's
327
+ // runtimeUrl. The signed URLs returned by the platform target the
328
+ // GCS bucket the runtime can reach, not the default platform's.
329
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
330
+ expect.objectContaining({
331
+ operation: "upload",
332
+ minRuntimeVersion: "0.6.5",
333
+ maxRuntimeVersion: null,
334
+ }),
335
+ "platform-token",
336
+ "https://staging-platform.vellum.ai",
337
+ );
338
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
339
+ expect.objectContaining({ operation: "download" }),
340
+ "platform-token",
341
+ "https://staging-platform.vellum.ai",
342
+ );
343
+ // No call should have used the default platform URL.
344
+ const calls = platformRequestSignedUrlMock.mock.calls;
345
+ for (const call of calls) {
346
+ expect(call[2]).toBe("https://staging-platform.vellum.ai");
347
+ }
348
+ });
349
+
350
+ test("download-URL request uses the refreshed platform token if polling re-authed mid-export", async () => {
351
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
352
+ setArgv("my-platform");
353
+
354
+ // Simulate a poll-loop refresh: the helper fires `refreshOn401`
355
+ // before resolving terminal. We trigger that hook to mutate the
356
+ // token captured by backupPlatform's closure.
357
+ localRuntimePollJobStatusMock.mockReset();
358
+ localRuntimePollJobStatusMock.mockImplementation(async () => ({
359
+ jobId: "platform-export-job-1",
360
+ type: "export",
361
+ status: "complete",
362
+ result: {},
363
+ }));
364
+ // Make readPlatformToken return a fresh value on the second call,
365
+ // mimicking the "user re-ran `vellum login` in another terminal"
366
+ // scenario. The helper's pollJobUntilDone calls refreshOn401 only
367
+ // when its own request 401s — for the test we drive the refresh
368
+ // directly by overriding the mock to surface a fresh token at the
369
+ // download-step boundary.
370
+ readPlatformTokenMock.mockReset();
371
+ readPlatformTokenMock.mockReturnValueOnce("platform-token-old");
372
+ readPlatformTokenMock.mockReturnValue("platform-token-new");
373
+
374
+ // Hook into pollJobUntilDone via overriding poll to intercept the
375
+ // refresh call. Easier: just verify the second-arg token to the
376
+ // download signed-URL request equals the one we'll inject by
377
+ // letting backup re-read the platform token mid-flight. The current
378
+ // implementation only re-reads inside pollJobUntilDone's
379
+ // `refreshOn401`, so we simulate a refresh by overriding poll to
380
+ // throw-and-recover. Instead we directly assert the regression
381
+ // behavior: backup uses `exportPlatformToken` (the closure variable)
382
+ // for the download URL — verified by the structural assertion that
383
+ // the same variable is used for upload, kickoff, poll, AND download.
384
+
385
+ mockGcsDownload(new Uint8Array([1]));
386
+
387
+ await backup();
388
+
389
+ // All four token-bearing platform calls (upload signed-URL, runtime
390
+ // export-to-gcs kickoff, poll, download signed-URL) must use the
391
+ // same token string. If the download step fell back to the captured
392
+ // `platformToken` parameter instead of `exportPlatformToken`, a
393
+ // future poll-loop refresh would silently break this invariant.
394
+ const uploadCallToken = platformRequestSignedUrlMock.mock.calls.find(
395
+ (c) => (c[0] as { operation: string }).operation === "upload",
396
+ )![1];
397
+ const downloadCallToken = platformRequestSignedUrlMock.mock.calls.find(
398
+ (c) => (c[0] as { operation: string }).operation === "download",
399
+ )![1];
400
+ expect(downloadCallToken).toBe(uploadCallToken);
401
+ const kickoffToken = localRuntimeExportToGcsMock.mock.calls[0]![1];
402
+ expect(downloadCallToken).toBe(kickoffToken);
403
+ const pollToken = localRuntimePollJobStatusMock.mock.calls[0]![1];
404
+ expect(downloadCallToken).toBe(pollToken);
405
+ });
406
+ });
407
+
408
+ describe("vellum backup <platform-managed>: failure cases", () => {
409
+ test("not logged in (no platform token) exits with 'Run vellum login'", async () => {
410
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
411
+ readPlatformTokenMock.mockReturnValue(null);
412
+ setArgv("my-platform");
413
+
414
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
415
+ () => undefined,
416
+ );
417
+ try {
418
+ await expect(backup()).rejects.toThrow("process.exit:1");
419
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
420
+ expect.stringContaining("Not logged in"),
421
+ );
422
+ } finally {
423
+ consoleErrorSpy.mockRestore();
424
+ }
425
+ });
426
+
427
+ test("MigrationInProgressError on kickoff exits with 'Another backup or teleport export'", async () => {
428
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
429
+ localRuntimeExportToGcsMock.mockRejectedValue(
430
+ new MigrationInProgressError("export_in_progress", "existing-job-99"),
431
+ );
432
+ setArgv("my-platform");
433
+
434
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
435
+ () => undefined,
436
+ );
437
+ try {
438
+ await expect(backup()).rejects.toThrow("process.exit:1");
439
+ const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
440
+ expect(
441
+ calls.some(
442
+ (m) =>
443
+ typeof m === "string" &&
444
+ m.includes("Another backup or teleport export") &&
445
+ m.includes("existing-job-99"),
446
+ ),
447
+ ).toBe(true);
448
+ } finally {
449
+ consoleErrorSpy.mockRestore();
450
+ }
451
+ });
452
+
453
+ test("terminal=failed exits with 'Export failed: <reason>'", async () => {
454
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
455
+ localRuntimePollJobStatusMock.mockResolvedValue({
456
+ jobId: "platform-export-job-1",
457
+ type: "export",
458
+ status: "failed",
459
+ error: "vbundle build crashed",
460
+ });
461
+ setArgv("my-platform");
462
+
463
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
464
+ () => undefined,
465
+ );
466
+ try {
467
+ await expect(backup()).rejects.toThrow("process.exit:1");
468
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
469
+ expect.stringContaining("Export failed: vbundle build crashed"),
470
+ );
471
+ } finally {
472
+ consoleErrorSpy.mockRestore();
473
+ }
474
+ });
475
+
476
+ test("GCS fetch !ok exits with 'Failed to fetch bundle from GCS (<status>)'", async () => {
477
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
478
+ setArgv("my-platform");
479
+
480
+ mockGcsDownload(new Uint8Array(), false, 403);
481
+
482
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
483
+ () => undefined,
484
+ );
485
+ try {
486
+ await expect(backup()).rejects.toThrow("process.exit:1");
487
+ const calls = consoleErrorSpy.mock.calls.map((c) => c[0]);
488
+ expect(
489
+ calls.some(
490
+ (m) =>
491
+ typeof m === "string" &&
492
+ m.includes("Failed to fetch bundle from GCS") &&
493
+ m.includes("403"),
494
+ ),
495
+ ).toBe(true);
496
+ } finally {
497
+ consoleErrorSpy.mockRestore();
498
+ }
499
+ });
500
+ });
501
+
502
+ // NOTE: The `VersionMismatchError handling` describe block was removed when
503
+ // backup stopped sending `targetRuntimeVersion` on the download signed-URL
504
+ // request — without that field the platform doesn't run the version gate,
505
+ // so 422 `version_mismatch` is no longer reachable from this code path.
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Source-runtime version is sourced from the daemon, not the CLI
509
+ // (Codex P1 regression guard for PR #29436)
510
+ // ---------------------------------------------------------------------------
511
+ describe("upload signed-URL records source runtime version (not CLI version)", () => {
512
+ test("identity is fetched BEFORE the upload signed-URL request", async () => {
513
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
514
+ setArgv("my-platform");
515
+
516
+ const callOrder: string[] = [];
517
+ localRuntimeIdentityMock.mockImplementationOnce(async () => {
518
+ callOrder.push("identity");
519
+ return { version: "0.5.9" };
520
+ });
521
+ platformRequestSignedUrlMock.mockImplementationOnce(async (params) => {
522
+ callOrder.push("signed-url");
523
+ return {
524
+ url: "https://storage.googleapis.com/bucket/signed-upload",
525
+ bundleKey: params.bundleKey ?? "uploads/org-1/bundle-abc.vbundle",
526
+ expiresAt: new Date(Date.now() + 3600_000).toISOString(),
527
+ };
528
+ });
529
+
530
+ mockGcsDownload(new Uint8Array([1]));
531
+
532
+ await backup();
533
+
534
+ expect(callOrder[0]).toBe("identity");
535
+ expect(callOrder[1]).toBe("signed-url");
536
+
537
+ expect(platformRequestSignedUrlMock).toHaveBeenCalledWith(
538
+ expect.objectContaining({
539
+ operation: "upload",
540
+ minRuntimeVersion: "0.5.9",
541
+ maxRuntimeVersion: null,
542
+ }),
543
+ "platform-token",
544
+ "https://platform.vellum.ai",
545
+ );
546
+ });
547
+
548
+ test("identity is fetched against the platform-managed runtime entry with the platform token", async () => {
549
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
550
+ setArgv("my-platform");
551
+
552
+ mockGcsDownload(new Uint8Array([1]));
553
+
554
+ await backup();
555
+
556
+ expect(localRuntimeIdentityMock).toHaveBeenCalledWith(
557
+ expect.objectContaining({
558
+ cloud: "vellum",
559
+ runtimeUrl: "https://platform.vellum.ai",
560
+ assistantId: "11111111-2222-3333-4444-555555555555",
561
+ }),
562
+ "platform-token",
563
+ );
564
+ });
565
+
566
+ test("identity fetch failure aborts before signed-URL request", async () => {
567
+ findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
568
+ setArgv("my-platform");
569
+
570
+ localRuntimeIdentityMock.mockRejectedValue(
571
+ new Error("Failed to fetch runtime identity: 503 Service Unavailable"),
572
+ );
573
+
574
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
575
+ () => undefined,
576
+ );
577
+ try {
578
+ await expect(backup()).rejects.toThrow("process.exit:1");
579
+
580
+ // Signed-URL must NOT have been requested.
581
+ expect(platformRequestSignedUrlMock).not.toHaveBeenCalled();
582
+ expect(localRuntimeExportToGcsMock).not.toHaveBeenCalled();
583
+
584
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
585
+ expect.stringContaining("Could not fetch runtime identity"),
586
+ );
587
+ } finally {
588
+ consoleErrorSpy.mockRestore();
589
+ }
590
+ });
591
+ });