@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,8 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { AssistantEntry } from "../assistant-config.js";
3
4
  import {
4
5
  MigrationInProgressError,
5
6
  localRuntimeExportToGcs,
7
+ localRuntimeIdentity,
6
8
  localRuntimeImportFromGcs,
7
9
  localRuntimePollJobStatus,
8
10
  } from "../local-runtime-client.js";
@@ -10,6 +12,17 @@ import {
10
12
  const RUNTIME_URL = "http://127.0.0.1:8765";
11
13
  const TOKEN = "local-bearer-token";
12
14
 
15
+ // All tests in this file exercise the local/docker code path (cloud="local"),
16
+ // which builds `{runtimeUrl}/v1/migrations/<subpath>` URLs and uses
17
+ // guardian-token bearer auth. The platform path (cloud="vellum") is covered
18
+ // by `runtime-url.test.ts` (URL construction) and the teleport tests
19
+ // (call-site wiring).
20
+ const ENTRY: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> = {
21
+ cloud: "local",
22
+ runtimeUrl: RUNTIME_URL,
23
+ assistantId: "ast-test-1",
24
+ };
25
+
13
26
  interface CapturedCall {
14
27
  url: string;
15
28
  method: string;
@@ -82,7 +95,7 @@ describe("localRuntimeExportToGcs", () => {
82
95
  });
83
96
  globalThis.fetch = fetchMock;
84
97
 
85
- const result = await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
98
+ const result = await localRuntimeExportToGcs(ENTRY, TOKEN, {
86
99
  uploadUrl: "https://storage.example/signed/abc",
87
100
  description: "teleport export",
88
101
  });
@@ -108,7 +121,7 @@ describe("localRuntimeExportToGcs", () => {
108
121
  });
109
122
  globalThis.fetch = fetchMock;
110
123
 
111
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
124
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
112
125
  uploadUrl: "https://storage.example/signed/abc",
113
126
  });
114
127
 
@@ -132,7 +145,7 @@ describe("localRuntimeExportToGcs", () => {
132
145
  globalThis.fetch = fetchMock;
133
146
 
134
147
  try {
135
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
148
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
136
149
  uploadUrl: "https://storage.example/signed/abc",
137
150
  });
138
151
  throw new Error("expected to throw");
@@ -156,7 +169,7 @@ describe("localRuntimeExportToGcs", () => {
156
169
  globalThis.fetch = fetchMock;
157
170
 
158
171
  try {
159
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
172
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
160
173
  uploadUrl: "https://storage.example/signed/abc",
161
174
  });
162
175
  throw new Error("expected to throw");
@@ -182,7 +195,7 @@ describe("localRuntimeExportToGcs", () => {
182
195
  globalThis.fetch = fetchMock;
183
196
 
184
197
  try {
185
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
198
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
186
199
  uploadUrl: "https://storage.example/signed/abc",
187
200
  });
188
201
  throw new Error("expected to throw");
@@ -201,7 +214,7 @@ describe("localRuntimeExportToGcs", () => {
201
214
  globalThis.fetch = fetchMock;
202
215
 
203
216
  await expect(
204
- localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
217
+ localRuntimeExportToGcs(ENTRY, TOKEN, {
205
218
  uploadUrl: "https://storage.example/signed/abc",
206
219
  }),
207
220
  ).rejects.toThrow(/500/);
@@ -222,7 +235,7 @@ describe("localRuntimeImportFromGcs", () => {
222
235
  });
223
236
  globalThis.fetch = fetchMock;
224
237
 
225
- const result = await localRuntimeImportFromGcs(RUNTIME_URL, TOKEN, {
238
+ const result = await localRuntimeImportFromGcs(ENTRY, TOKEN, {
226
239
  bundleUrl: "https://storage.example/signed/dl-xyz",
227
240
  });
228
241
 
@@ -250,7 +263,7 @@ describe("localRuntimeImportFromGcs", () => {
250
263
  globalThis.fetch = fetchMock;
251
264
 
252
265
  try {
253
- await localRuntimeImportFromGcs(RUNTIME_URL, TOKEN, {
266
+ await localRuntimeImportFromGcs(ENTRY, TOKEN, {
254
267
  bundleUrl: "https://storage.example/signed/dl-xyz",
255
268
  });
256
269
  throw new Error("expected to throw");
@@ -275,7 +288,7 @@ describe("localRuntimeImportFromGcs", () => {
275
288
  globalThis.fetch = fetchMock;
276
289
 
277
290
  try {
278
- await localRuntimeImportFromGcs(RUNTIME_URL, TOKEN, {
291
+ await localRuntimeImportFromGcs(ENTRY, TOKEN, {
279
292
  bundleUrl: "https://storage.example/signed/dl-xyz",
280
293
  });
281
294
  throw new Error("expected to throw");
@@ -302,11 +315,7 @@ describe("localRuntimePollJobStatus", () => {
302
315
  });
303
316
  globalThis.fetch = fetchMock;
304
317
 
305
- const status = await localRuntimePollJobStatus(
306
- RUNTIME_URL,
307
- TOKEN,
308
- "poll-1",
309
- );
318
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-1");
310
319
 
311
320
  expect(status).toEqual({
312
321
  jobId: "poll-1",
@@ -332,11 +341,7 @@ describe("localRuntimePollJobStatus", () => {
332
341
  });
333
342
  globalThis.fetch = fetchMock;
334
343
 
335
- const status = await localRuntimePollJobStatus(
336
- RUNTIME_URL,
337
- TOKEN,
338
- "poll-2",
339
- );
344
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-2");
340
345
 
341
346
  expect(status.status).toBe("complete");
342
347
  if (status.status === "complete") {
@@ -358,11 +363,7 @@ describe("localRuntimePollJobStatus", () => {
358
363
  });
359
364
  globalThis.fetch = fetchMock;
360
365
 
361
- const status = await localRuntimePollJobStatus(
362
- RUNTIME_URL,
363
- TOKEN,
364
- "poll-3",
365
- );
366
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-3");
366
367
 
367
368
  expect(status.status).toBe("failed");
368
369
  if (status.status === "failed") {
@@ -377,7 +378,289 @@ describe("localRuntimePollJobStatus", () => {
377
378
  globalThis.fetch = fetchMock;
378
379
 
379
380
  await expect(
380
- localRuntimePollJobStatus(RUNTIME_URL, TOKEN, "missing"),
381
+ localRuntimePollJobStatus(ENTRY, TOKEN, "missing"),
381
382
  ).rejects.toThrow(/Migration job not found/);
382
383
  });
383
384
  });
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Platform-managed assistants (cloud="vellum") route through the platform's
388
+ // wildcard runtime proxy at `/v1/assistants/<id>/migrations/...` with
389
+ // platform-token auth (NOT guardian-token bearer). This block asserts the
390
+ // actual URL and headers built by the helpers — not mocked, not abstracted.
391
+ // Regression guard for the routing bug fixed in this PR.
392
+ // ---------------------------------------------------------------------------
393
+ const VELLUM_ENTRY: Pick<
394
+ AssistantEntry,
395
+ "cloud" | "runtimeUrl" | "assistantId"
396
+ > = {
397
+ cloud: "vellum",
398
+ runtimeUrl: "https://platform.vellum.ai",
399
+ assistantId: "11111111-2222-3333-4444-555555555555",
400
+ };
401
+ // `vak_` prefix bypasses `fetchOrganizationId` (org-scoped API keys); the
402
+ // auth header collapses to a single `Authorization: Bearer vak_...` so this
403
+ // test stays free of network mocks.
404
+ const VAK_TOKEN = "vak_platform-token";
405
+
406
+ describe("vellum-cloud routing through wildcard proxy", () => {
407
+ test("export-to-gcs URL has /v1/assistants/<id>/migrations/ prefix and uses platform-token bearer (no guardian)", async () => {
408
+ const { calls, fetchMock } = captureFetch(() => {
409
+ return new Response(
410
+ JSON.stringify({ job_id: "wp-export-1", status: "pending" }),
411
+ { status: 202, headers: { "Content-Type": "application/json" } },
412
+ );
413
+ });
414
+ globalThis.fetch = fetchMock;
415
+
416
+ const result = await localRuntimeExportToGcs(VELLUM_ENTRY, VAK_TOKEN, {
417
+ uploadUrl: "https://storage.example/signed/x",
418
+ description: "teleport export",
419
+ });
420
+
421
+ expect(result.jobId).toBe("wp-export-1");
422
+ expect(calls[0]!.url).toBe(
423
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs`,
424
+ );
425
+ expect(calls[0]!.method).toBe("POST");
426
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
427
+ expect(calls[0]!.body).toEqual({
428
+ upload_url: "https://storage.example/signed/x",
429
+ description: "teleport export",
430
+ });
431
+ });
432
+
433
+ test("import-from-gcs URL has /v1/assistants/<id>/migrations/ prefix", async () => {
434
+ const { calls, fetchMock } = captureFetch(() => {
435
+ return new Response(
436
+ JSON.stringify({ job_id: "wp-import-1", status: "pending" }),
437
+ { status: 202 },
438
+ );
439
+ });
440
+ globalThis.fetch = fetchMock;
441
+
442
+ await localRuntimeImportFromGcs(VELLUM_ENTRY, VAK_TOKEN, {
443
+ bundleUrl: "https://storage.example/download/y",
444
+ });
445
+
446
+ expect(calls[0]!.url).toBe(
447
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs`,
448
+ );
449
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
450
+ });
451
+
452
+ test("jobs/<id> URL has /v1/assistants/<id>/migrations/ prefix (NOT the dedicated platform endpoint)", async () => {
453
+ const { calls, fetchMock } = captureFetch(() => {
454
+ return new Response(
455
+ JSON.stringify({
456
+ job_id: "wp-export-1",
457
+ status: "complete",
458
+ type: "export",
459
+ bundle_key: "exports/org-1/x.vbundle",
460
+ }),
461
+ { status: 200 },
462
+ );
463
+ });
464
+ globalThis.fetch = fetchMock;
465
+
466
+ const status = await localRuntimePollJobStatus(
467
+ VELLUM_ENTRY,
468
+ VAK_TOKEN,
469
+ "wp-export-1",
470
+ );
471
+
472
+ expect(calls[0]!.url).toBe(
473
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/wp-export-1`,
474
+ );
475
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
476
+ expect(status.status).toBe("complete");
477
+ if (status.status === "complete") {
478
+ expect(status.bundleKey).toBe("exports/org-1/x.vbundle");
479
+ }
480
+ });
481
+ });
482
+
483
+ describe("localRuntimeIdentity", () => {
484
+ test("local entry: GETs /v1/health with Bearer auth and returns the version", async () => {
485
+ const { calls, fetchMock } = captureFetch(() => {
486
+ return new Response(
487
+ JSON.stringify({
488
+ status: "healthy",
489
+ timestamp: "2025-01-01T00:00:00Z",
490
+ version: "0.6.5",
491
+ }),
492
+ {
493
+ status: 200,
494
+ headers: { "Content-Type": "application/json" },
495
+ },
496
+ );
497
+ });
498
+ globalThis.fetch = fetchMock;
499
+
500
+ const result = await localRuntimeIdentity(ENTRY, TOKEN);
501
+
502
+ expect(result.version).toBe("0.6.5");
503
+ expect(calls).toHaveLength(1);
504
+ expect(calls[0]!.url).toBe(`${RUNTIME_URL}/v1/health`);
505
+ expect(calls[0]!.method).toBe("GET");
506
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${TOKEN}`);
507
+ });
508
+
509
+ test("GETs /v1/health, not /v1/identity (works on pre-onboarding runtimes)", async () => {
510
+ // Regression guard: /v1/identity reads IDENTITY.md (written during
511
+ // onboarding, NOT hatch) and 404s on freshly-hatched targets. /v1/health
512
+ // returns the version field unconditionally, so it's the right source.
513
+ const { calls, fetchMock } = captureFetch(() => {
514
+ return new Response(JSON.stringify({ version: "0.7.0" }), {
515
+ status: 200,
516
+ });
517
+ });
518
+ globalThis.fetch = fetchMock;
519
+
520
+ await localRuntimeIdentity(ENTRY, TOKEN);
521
+
522
+ expect(calls[0]!.url.endsWith("/v1/health")).toBe(true);
523
+ expect(calls[0]!.url).not.toContain("/v1/identity");
524
+ });
525
+
526
+ test("vellum entry: GETs /v1/assistants/<id>/health through the wildcard proxy", async () => {
527
+ const { calls, fetchMock } = captureFetch(() => {
528
+ return new Response(JSON.stringify({ version: "0.7.2" }), {
529
+ status: 200,
530
+ });
531
+ });
532
+ globalThis.fetch = fetchMock;
533
+
534
+ const result = await localRuntimeIdentity(VELLUM_ENTRY, VAK_TOKEN);
535
+
536
+ expect(result.version).toBe("0.7.2");
537
+ expect(calls[0]!.url).toBe(
538
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/health`,
539
+ );
540
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
541
+ });
542
+
543
+ test("non-2xx status throws with status + statusText", async () => {
544
+ const { fetchMock } = captureFetch(() => {
545
+ return new Response("nope", {
546
+ status: 503,
547
+ statusText: "Service Unavailable",
548
+ });
549
+ });
550
+ globalThis.fetch = fetchMock;
551
+
552
+ await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
553
+ /Failed to fetch runtime identity: 503/,
554
+ );
555
+ });
556
+
557
+ test("missing version in body throws", async () => {
558
+ const { fetchMock } = captureFetch(() => {
559
+ return new Response(JSON.stringify({}), { status: 200 });
560
+ });
561
+ globalThis.fetch = fetchMock;
562
+
563
+ await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
564
+ /Runtime identity response missing version/,
565
+ );
566
+ });
567
+
568
+ test("non-string version in body throws", async () => {
569
+ const { fetchMock } = captureFetch(() => {
570
+ return new Response(JSON.stringify({ version: 123 }), { status: 200 });
571
+ });
572
+ globalThis.fetch = fetchMock;
573
+
574
+ await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
575
+ /Runtime identity response missing version/,
576
+ );
577
+ });
578
+
579
+ test("empty-string version in body throws", async () => {
580
+ const { fetchMock } = captureFetch(() => {
581
+ return new Response(JSON.stringify({ version: "" }), { status: 200 });
582
+ });
583
+ globalThis.fetch = fetchMock;
584
+
585
+ await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
586
+ /Runtime identity response missing version/,
587
+ );
588
+ });
589
+
590
+ // ---------------------------------------------------------------------
591
+ // 401-retry parity with platformRequestSignedUrl (Codex P2 regression
592
+ // guard). localRuntimeIdentity is the first network call in the
593
+ // backup/teleport export flow for vellum-cloud assistants, so a stale
594
+ // Vellum-Organization-Id cache entry would surface as a hard abort
595
+ // before any other helper got a chance to clear the cache and retry.
596
+ // ---------------------------------------------------------------------
597
+
598
+ test("vellum entry: retries once after 401 with a fresh org-ID lookup", async () => {
599
+ // Use a non-vak session token so authHeaders fetches + caches an org ID.
600
+ const SESSION_TOKEN = "session-abcdef";
601
+ const PLATFORM_URL = "https://platform.vellum.ai";
602
+ const ASSISTANT_ID = "11111111-2222-3333-4444-555555555555";
603
+
604
+ let healthCalls = 0;
605
+ const orgIdFetchedAs: string[] = [];
606
+
607
+ const fetchMock = mock(async (url: string | URL | Request) => {
608
+ const urlStr = typeof url === "string" ? url : url.toString();
609
+ if (urlStr.endsWith("/v1/organizations/")) {
610
+ // Each org-ID fetch returns a different ID to prove that the
611
+ // second health request DID re-resolve the org rather than
612
+ // reuse a stale cache entry.
613
+ orgIdFetchedAs.push(`org-${orgIdFetchedAs.length + 1}`);
614
+ return new Response(
615
+ JSON.stringify({
616
+ results: [{ id: orgIdFetchedAs[orgIdFetchedAs.length - 1]! }],
617
+ }),
618
+ { status: 200 },
619
+ );
620
+ }
621
+ if (urlStr.endsWith(`/v1/assistants/${ASSISTANT_ID}/health`)) {
622
+ healthCalls += 1;
623
+ if (healthCalls === 1) {
624
+ return new Response("unauthorized", { status: 401 });
625
+ }
626
+ return new Response(JSON.stringify({ version: "0.7.4" }), {
627
+ status: 200,
628
+ });
629
+ }
630
+ return new Response("unexpected", { status: 500 });
631
+ });
632
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
633
+
634
+ const result = await localRuntimeIdentity(
635
+ {
636
+ cloud: "vellum",
637
+ runtimeUrl: PLATFORM_URL,
638
+ assistantId: ASSISTANT_ID,
639
+ },
640
+ SESSION_TOKEN,
641
+ );
642
+
643
+ expect(result.version).toBe("0.7.4");
644
+ expect(healthCalls).toBe(2);
645
+ // Two org-ID fetches: the first to satisfy the initial authHeaders
646
+ // call, the second after the 401-driven cache invalidation.
647
+ expect(orgIdFetchedAs).toEqual(["org-1", "org-2"]);
648
+ });
649
+
650
+ test("local entry: does NOT retry after 401 (guardian-token refresh is the caller's job via callRuntimeWithAuthRetry)", async () => {
651
+ let identityCalls = 0;
652
+ const fetchMock = mock(async () => {
653
+ identityCalls += 1;
654
+ return new Response("unauthorized", {
655
+ status: 401,
656
+ statusText: "Unauthorized",
657
+ });
658
+ });
659
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
660
+
661
+ await expect(localRuntimeIdentity(ENTRY, TOKEN)).rejects.toThrow(
662
+ /Failed to fetch runtime identity: 401/,
663
+ );
664
+ expect(identityCalls).toBe(1);
665
+ });
666
+ });
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3
3
  import {
4
4
  platformPollJobStatus,
5
5
  platformRequestSignedUrl,
6
+ VersionMismatchError,
6
7
  type UnifiedJobStatus,
7
8
  } from "../platform-client.js";
8
9
 
@@ -291,7 +292,241 @@ describe("platformRequestSignedUrl", () => {
291
292
  expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
292
293
  });
293
294
 
294
- test("503 throws so callers can fall back to legacy inline upload", async () => {
295
+ test("upload operation with min/max runtime versions posts them in body", async () => {
296
+ const { calls, fetchMock } = captureFetch(() => {
297
+ return new Response(
298
+ JSON.stringify({
299
+ url: "https://storage.example/signed/v",
300
+ bundle_key: "bundles/v.tar.gz",
301
+ expires_at: "2026-04-22T05:00:00Z",
302
+ }),
303
+ { status: 201 },
304
+ );
305
+ });
306
+ globalThis.fetch = fetchMock;
307
+
308
+ await platformRequestSignedUrl(
309
+ {
310
+ operation: "upload",
311
+ minRuntimeVersion: "1.2.3",
312
+ maxRuntimeVersion: null,
313
+ },
314
+ VAK_TOKEN,
315
+ PLATFORM_URL,
316
+ );
317
+
318
+ expect(calls[0]!.body).toEqual({
319
+ operation: "upload",
320
+ min_runtime_version: "1.2.3",
321
+ max_runtime_version: null,
322
+ });
323
+ });
324
+
325
+ test("download operation with target_runtime_version → posts it in body", async () => {
326
+ const { calls, fetchMock } = captureFetch(() => {
327
+ return new Response(
328
+ JSON.stringify({
329
+ url: "https://storage.example/signed/dl-v",
330
+ bundle_key: "bundles/dl-v.tar.gz",
331
+ expires_at: "2026-04-22T06:00:00Z",
332
+ }),
333
+ { status: 201 },
334
+ );
335
+ });
336
+ globalThis.fetch = fetchMock;
337
+
338
+ await platformRequestSignedUrl(
339
+ {
340
+ operation: "download",
341
+ bundleKey: "bundles/dl-v.tar.gz",
342
+ targetRuntimeVersion: "2.0.0",
343
+ },
344
+ VAK_TOKEN,
345
+ PLATFORM_URL,
346
+ );
347
+
348
+ expect(calls[0]!.body).toEqual({
349
+ operation: "download",
350
+ bundle_key: "bundles/dl-v.tar.gz",
351
+ target_runtime_version: "2.0.0",
352
+ });
353
+ });
354
+
355
+ test("download 422 with version_mismatch body → throws VersionMismatchError", async () => {
356
+ const { fetchMock } = captureFetch(() => {
357
+ return new Response(
358
+ JSON.stringify({
359
+ reason: "version_mismatch",
360
+ bundle_compat: {
361
+ min_runtime_version: "2.0.0",
362
+ max_runtime_version: "2.5.0",
363
+ },
364
+ target_runtime_version: "1.9.0",
365
+ }),
366
+ { status: 422 },
367
+ );
368
+ });
369
+ globalThis.fetch = fetchMock;
370
+
371
+ let caught: unknown;
372
+ try {
373
+ await platformRequestSignedUrl(
374
+ {
375
+ operation: "download",
376
+ bundleKey: "bundles/foo.tar.gz",
377
+ targetRuntimeVersion: "1.9.0",
378
+ },
379
+ VAK_TOKEN,
380
+ PLATFORM_URL,
381
+ );
382
+ } catch (err) {
383
+ caught = err;
384
+ }
385
+
386
+ expect(caught).toBeInstanceOf(VersionMismatchError);
387
+ expect(caught).toBeInstanceOf(Error);
388
+ const err = caught as VersionMismatchError;
389
+ expect(err.bundleCompat).toEqual({
390
+ min_runtime_version: "2.0.0",
391
+ max_runtime_version: "2.5.0",
392
+ });
393
+ expect(err.targetRuntimeVersion).toBe("1.9.0");
394
+ expect(err.message).toBe(
395
+ "Cannot import: bundle requires runtime 2.0.0–2.5.0, but this runtime is 1.9.0. Update your runtime before importing.",
396
+ );
397
+ });
398
+
399
+ test("download 422 with version_mismatch and null max_runtime_version → '+' suffix in message", async () => {
400
+ const { fetchMock } = captureFetch(() => {
401
+ return new Response(
402
+ JSON.stringify({
403
+ reason: "version_mismatch",
404
+ bundle_compat: {
405
+ min_runtime_version: "3.0.0",
406
+ max_runtime_version: null,
407
+ },
408
+ target_runtime_version: "2.9.0",
409
+ }),
410
+ { status: 422 },
411
+ );
412
+ });
413
+ globalThis.fetch = fetchMock;
414
+
415
+ let caught: unknown;
416
+ try {
417
+ await platformRequestSignedUrl(
418
+ {
419
+ operation: "download",
420
+ bundleKey: "bundles/foo.tar.gz",
421
+ targetRuntimeVersion: "2.9.0",
422
+ },
423
+ VAK_TOKEN,
424
+ PLATFORM_URL,
425
+ );
426
+ } catch (err) {
427
+ caught = err;
428
+ }
429
+
430
+ expect(caught).toBeInstanceOf(VersionMismatchError);
431
+ const err = caught as VersionMismatchError;
432
+ expect(err.message).toBe(
433
+ "Cannot import: bundle requires runtime 3.0.0+, but this runtime is 2.9.0. Update your runtime before importing.",
434
+ );
435
+ });
436
+
437
+ test("download 422 with arbitrary body (no reason field) → falls through to generic Error", async () => {
438
+ const { fetchMock } = captureFetch(() => {
439
+ return new Response(JSON.stringify({ detail: "validation failed" }), {
440
+ status: 422,
441
+ });
442
+ });
443
+ globalThis.fetch = fetchMock;
444
+
445
+ let caught: unknown;
446
+ try {
447
+ await platformRequestSignedUrl(
448
+ { operation: "download", bundleKey: "bundles/foo.tar.gz" },
449
+ VAK_TOKEN,
450
+ PLATFORM_URL,
451
+ );
452
+ } catch (err) {
453
+ caught = err;
454
+ }
455
+
456
+ expect(caught).toBeInstanceOf(Error);
457
+ expect(caught).not.toBeInstanceOf(VersionMismatchError);
458
+ expect((caught as Error).message).toBe("validation failed");
459
+ });
460
+
461
+ test("422 is NOT retried after a 401", async () => {
462
+ let callCount = 0;
463
+ const { calls, fetchMock } = captureFetch(() => {
464
+ callCount += 1;
465
+ if (callCount === 1) {
466
+ return new Response(JSON.stringify({ detail: "unauthorized" }), {
467
+ status: 401,
468
+ });
469
+ }
470
+ return new Response(
471
+ JSON.stringify({
472
+ reason: "version_mismatch",
473
+ bundle_compat: {
474
+ min_runtime_version: "2.0.0",
475
+ max_runtime_version: "2.5.0",
476
+ },
477
+ target_runtime_version: "1.9.0",
478
+ }),
479
+ { status: 422 },
480
+ );
481
+ });
482
+ globalThis.fetch = fetchMock;
483
+
484
+ let caught: unknown;
485
+ try {
486
+ await platformRequestSignedUrl(
487
+ {
488
+ operation: "download",
489
+ bundleKey: "bundles/foo.tar.gz",
490
+ targetRuntimeVersion: "1.9.0",
491
+ },
492
+ VAK_TOKEN,
493
+ PLATFORM_URL,
494
+ );
495
+ } catch (err) {
496
+ caught = err;
497
+ }
498
+
499
+ expect(calls).toHaveLength(2);
500
+ expect(caught).toBeInstanceOf(VersionMismatchError);
501
+ });
502
+
503
+ test("non-422 download path remains unchanged (regression pin on body shape)", async () => {
504
+ const { calls, fetchMock } = captureFetch(() => {
505
+ return new Response(
506
+ JSON.stringify({
507
+ url: "https://storage.example/signed/dl-xyz",
508
+ bundle_key: "bundles/xyz.tar.gz",
509
+ expires_at: "2026-04-22T02:00:00Z",
510
+ }),
511
+ { status: 201 },
512
+ );
513
+ });
514
+ globalThis.fetch = fetchMock;
515
+
516
+ const result = await platformRequestSignedUrl(
517
+ { operation: "download", bundleKey: "bundles/xyz.tar.gz" },
518
+ VAK_TOKEN,
519
+ PLATFORM_URL,
520
+ );
521
+
522
+ expect(result.url).toBe("https://storage.example/signed/dl-xyz");
523
+ expect(calls[0]!.body).toEqual({
524
+ operation: "download",
525
+ bundle_key: "bundles/xyz.tar.gz",
526
+ });
527
+ });
528
+
529
+ test("5xx error response → surfaces platform detail message", async () => {
295
530
  const { fetchMock } = captureFetch(() => {
296
531
  return new Response(JSON.stringify({ detail: "temporarily down" }), {
297
532
  status: 503,
@@ -305,7 +540,7 @@ describe("platformRequestSignedUrl", () => {
305
540
  VAK_TOKEN,
306
541
  PLATFORM_URL,
307
542
  ),
308
- ).rejects.toThrow(/503/);
543
+ ).rejects.toThrow(/temporarily down/);
309
544
  });
310
545
  });
311
546