@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.
- package/AGENTS.md +3 -11
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +121 -5
- package/src/__tests__/teleport.test.ts +515 -10
- package/src/commands/backup.ts +35 -2
- package/src/commands/client.ts +90 -7
- package/src/commands/exec.ts +13 -4
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +11 -0
- package/src/commands/restore.ts +7 -1
- package/src/commands/rollback.ts +1 -1
- package/src/commands/setup.ts +38 -73
- package/src/commands/teleport.ts +122 -12
- package/src/commands/upgrade.ts +8 -2
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +42 -130
- package/src/index.ts +1 -7
- package/src/lib/__tests__/docker.test.ts +53 -35
- package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
- package/src/lib/__tests__/runtime-url.test.ts +39 -1
- package/src/lib/assistant-client.ts +13 -5
- package/src/lib/assistant-config.ts +0 -25
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/client-identity.ts +9 -5
- package/src/lib/docker.ts +6 -267
- package/src/lib/environments/paths.ts +20 -0
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/local-runtime-client.ts +82 -1
- package/src/lib/local.ts +9 -7
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +100 -1
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +22 -0
- package/src/lib/statefulset.ts +375 -0
- package/src/lib/upgrade-lifecycle.ts +97 -1
- package/src/commands/pair.ts +0 -212
|
@@ -4,6 +4,7 @@ import type { AssistantEntry } from "../assistant-config.js";
|
|
|
4
4
|
import {
|
|
5
5
|
MigrationInProgressError,
|
|
6
6
|
localRuntimeExportToGcs,
|
|
7
|
+
localRuntimeIdentity,
|
|
7
8
|
localRuntimeImportFromGcs,
|
|
8
9
|
localRuntimePollJobStatus,
|
|
9
10
|
} from "../local-runtime-client.js";
|
|
@@ -478,3 +479,188 @@ describe("vellum-cloud routing through wildcard proxy", () => {
|
|
|
478
479
|
}
|
|
479
480
|
});
|
|
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,6 +292,240 @@ describe("platformRequestSignedUrl", () => {
|
|
|
291
292
|
expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
|
|
292
293
|
});
|
|
293
294
|
|
|
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
|
+
|
|
294
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" }), {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import type { AssistantEntry } from "../assistant-config.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
resolveRuntimeMigrationUrl,
|
|
6
|
+
resolveRuntimeUrl,
|
|
7
|
+
} from "../runtime-url.js";
|
|
5
8
|
|
|
6
9
|
function makeEntry(
|
|
7
10
|
overrides: Partial<AssistantEntry> & {
|
|
@@ -85,3 +88,38 @@ describe("resolveRuntimeMigrationUrl", () => {
|
|
|
85
88
|
);
|
|
86
89
|
});
|
|
87
90
|
});
|
|
91
|
+
|
|
92
|
+
describe("resolveRuntimeUrl", () => {
|
|
93
|
+
test("local cloud uses gateway-loopback /v1/<subpath>", () => {
|
|
94
|
+
const entry = makeEntry({
|
|
95
|
+
cloud: "local",
|
|
96
|
+
runtimeUrl: "http://localhost:7821",
|
|
97
|
+
assistantId: "ast-local-1",
|
|
98
|
+
});
|
|
99
|
+
expect(resolveRuntimeUrl(entry, "identity")).toBe(
|
|
100
|
+
"http://localhost:7821/v1/identity",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("docker cloud uses gateway-loopback /v1/<subpath>", () => {
|
|
105
|
+
const entry = makeEntry({
|
|
106
|
+
cloud: "docker",
|
|
107
|
+
runtimeUrl: "http://localhost:7831",
|
|
108
|
+
assistantId: "ast-docker-1",
|
|
109
|
+
});
|
|
110
|
+
expect(resolveRuntimeUrl(entry, "identity")).toBe(
|
|
111
|
+
"http://localhost:7831/v1/identity",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("vellum cloud uses wildcard-proxy /v1/assistants/<id>/<subpath>", () => {
|
|
116
|
+
const entry = makeEntry({
|
|
117
|
+
cloud: "vellum",
|
|
118
|
+
runtimeUrl: "https://platform.vellum.ai",
|
|
119
|
+
assistantId: "11111111-2222-3333-4444-555555555555",
|
|
120
|
+
});
|
|
121
|
+
expect(resolveRuntimeUrl(entry, "identity")).toBe(
|
|
122
|
+
"https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/identity",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -26,8 +26,8 @@ export interface AssistantClientOpts {
|
|
|
26
26
|
/**
|
|
27
27
|
* When provided alongside `orgId`, the client authenticates with a
|
|
28
28
|
* session token instead of a guardian token. The session token is
|
|
29
|
-
* sent as `
|
|
30
|
-
* sent via the `
|
|
29
|
+
* sent as `X-Session-Token: <sessionToken>` and the org id is
|
|
30
|
+
* sent via the `Vellum-Organization-Id` header.
|
|
31
31
|
*/
|
|
32
32
|
sessionToken?: string;
|
|
33
33
|
/** Required when `sessionToken` is provided. */
|
|
@@ -46,6 +46,8 @@ export class AssistantClient {
|
|
|
46
46
|
|
|
47
47
|
private readonly _assistantId: string;
|
|
48
48
|
private readonly token: string | undefined;
|
|
49
|
+
/** True when token is a platform session token (X-Session-Token), false for guardian JWT (Authorization: Bearer). */
|
|
50
|
+
private readonly isSessionAuth: boolean;
|
|
49
51
|
private readonly orgId: string | undefined;
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -74,12 +76,14 @@ export class AssistantClient {
|
|
|
74
76
|
this._assistantId = entry.assistantId;
|
|
75
77
|
|
|
76
78
|
if (opts?.sessionToken) {
|
|
77
|
-
// Platform assistant: use
|
|
79
|
+
// Platform assistant: use X-Session-Token + Vellum-Organization-Id.
|
|
78
80
|
this.token = opts.sessionToken;
|
|
81
|
+
this.isSessionAuth = true;
|
|
79
82
|
this.orgId = opts.orgId;
|
|
80
83
|
} else {
|
|
81
84
|
this.token =
|
|
82
85
|
loadGuardianToken(this._assistantId)?.accessToken ?? entry.bearerToken;
|
|
86
|
+
this.isSessionAuth = false;
|
|
83
87
|
this.orgId = undefined;
|
|
84
88
|
}
|
|
85
89
|
}
|
|
@@ -175,10 +179,14 @@ export class AssistantClient {
|
|
|
175
179
|
|
|
176
180
|
const headers: Record<string, string> = { ...opts?.headers };
|
|
177
181
|
if (this.token) {
|
|
178
|
-
|
|
182
|
+
if (this.isSessionAuth) {
|
|
183
|
+
headers["X-Session-Token"] ??= this.token;
|
|
184
|
+
} else {
|
|
185
|
+
headers["Authorization"] ??= `Bearer ${this.token}`;
|
|
186
|
+
}
|
|
179
187
|
}
|
|
180
188
|
if (this.orgId) {
|
|
181
|
-
headers["
|
|
189
|
+
headers["Vellum-Organization-Id"] ??= this.orgId;
|
|
182
190
|
}
|
|
183
191
|
if (body !== undefined) {
|
|
184
192
|
headers["Content-Type"] = "application/json";
|
|
@@ -108,10 +108,6 @@ interface LockfileData {
|
|
|
108
108
|
[key: string]: unknown;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export function getBaseDir(): string {
|
|
112
|
-
return process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
111
|
/**
|
|
116
112
|
* Derive the daemon PID file path from a resources object. The PID file
|
|
117
113
|
* lives inside the instance's workspace directory. When no resources are
|
|
@@ -512,25 +508,4 @@ export function getLockfilePlatformBaseUrl(): string | undefined {
|
|
|
512
508
|
return undefined;
|
|
513
509
|
}
|
|
514
510
|
|
|
515
|
-
/**
|
|
516
|
-
* Read the assistant config file and sync client-relevant values to the
|
|
517
|
-
* lockfile. This lets external tools (e.g. vel) discover the platform URL
|
|
518
|
-
* without importing the assistant config schema.
|
|
519
|
-
*/
|
|
520
|
-
export function syncConfigToLockfile(): void {
|
|
521
|
-
const configPath = join(getBaseDir(), ".vellum", "workspace", "config.json");
|
|
522
|
-
if (!existsSync(configPath)) return;
|
|
523
511
|
|
|
524
|
-
try {
|
|
525
|
-
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
526
|
-
string,
|
|
527
|
-
unknown
|
|
528
|
-
>;
|
|
529
|
-
const platform = raw.platform as Record<string, unknown> | undefined;
|
|
530
|
-
const data = readLockfile();
|
|
531
|
-
data.platformBaseUrl = (platform?.baseUrl as string) || undefined;
|
|
532
|
-
writeLockfile(data);
|
|
533
|
-
} catch {
|
|
534
|
-
// Config file unreadable — skip sync
|
|
535
|
-
}
|
|
536
|
-
}
|
package/src/lib/backup-ops.ts
CHANGED
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { dirname, join } from "path";
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
loadGuardianToken,
|
|
14
|
+
refreshGuardianToken,
|
|
15
|
+
} from "./guardian-token.js";
|
|
13
16
|
|
|
14
17
|
/** Default backup directory following XDG convention */
|
|
15
18
|
export function getBackupsDir(): string {
|
|
@@ -25,20 +28,25 @@ export function formatSize(bytes: number): string {
|
|
|
25
28
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Obtain a valid guardian access token.
|
|
33
|
+
*
|
|
34
|
+
* Resolution order:
|
|
35
|
+
* 1. Cached token that is not yet expired — use as-is.
|
|
36
|
+
* 2. Cached token with a valid refresh token — call /v1/guardian/refresh.
|
|
37
|
+
* 3. No usable token — return null so callers can skip the backup gracefully
|
|
38
|
+
* rather than hitting /v1/guardian/init (which 403s on bootstrapped instances).
|
|
39
|
+
*/
|
|
29
40
|
async function getGuardianAccessToken(
|
|
30
41
|
runtimeUrl: string,
|
|
31
42
|
assistantId: string,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
37
|
-
return tokenData.accessToken;
|
|
38
|
-
}
|
|
43
|
+
): Promise<string | null> {
|
|
44
|
+
const tokenData = loadGuardianToken(assistantId);
|
|
45
|
+
if (tokenData && new Date(tokenData.accessTokenExpiresAt) > new Date()) {
|
|
46
|
+
return tokenData.accessToken;
|
|
39
47
|
}
|
|
40
|
-
const
|
|
41
|
-
return
|
|
48
|
+
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
49
|
+
return refreshed?.accessToken ?? null;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/**
|
|
@@ -53,6 +61,10 @@ export async function createBackup(
|
|
|
53
61
|
): Promise<string | null> {
|
|
54
62
|
try {
|
|
55
63
|
let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
|
|
64
|
+
if (!accessToken) {
|
|
65
|
+
console.warn("Warning: backup skipped — no valid guardian token available");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
56
68
|
|
|
57
69
|
let response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
|
|
58
70
|
method: "POST",
|
|
@@ -66,10 +78,15 @@ export async function createBackup(
|
|
|
66
78
|
signal: AbortSignal.timeout(120_000),
|
|
67
79
|
});
|
|
68
80
|
|
|
69
|
-
// Retry once with a
|
|
70
|
-
// after a container restart that
|
|
81
|
+
// Retry once with a refreshed token on 401 — the cached token may be
|
|
82
|
+
// stale after a container restart that regenerated the gateway signing key.
|
|
71
83
|
if (response.status === 401) {
|
|
72
|
-
|
|
84
|
+
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
85
|
+
if (!refreshed) {
|
|
86
|
+
console.warn(`Warning: backup export failed (401) and token refresh failed`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
accessToken = refreshed.accessToken;
|
|
73
90
|
response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
|
|
74
91
|
method: "POST",
|
|
75
92
|
headers: {
|
|
@@ -130,6 +147,10 @@ export async function restoreBackup(
|
|
|
130
147
|
|
|
131
148
|
const bundleData = readFileSync(backupPath);
|
|
132
149
|
let accessToken = await getGuardianAccessToken(runtimeUrl, assistantId);
|
|
150
|
+
if (!accessToken) {
|
|
151
|
+
console.warn("Warning: restore skipped — no valid guardian token available");
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
133
154
|
|
|
134
155
|
let response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
135
156
|
method: "POST",
|
|
@@ -141,10 +162,15 @@ export async function restoreBackup(
|
|
|
141
162
|
signal: AbortSignal.timeout(120_000),
|
|
142
163
|
});
|
|
143
164
|
|
|
144
|
-
// Retry once with a
|
|
145
|
-
// after a container restart that
|
|
165
|
+
// Retry once with a refreshed token on 401 — the cached token may be
|
|
166
|
+
// stale after a container restart that regenerated the gateway signing key.
|
|
146
167
|
if (response.status === 401) {
|
|
147
|
-
|
|
168
|
+
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
169
|
+
if (!refreshed) {
|
|
170
|
+
console.warn(`Warning: restore failed (401) and token refresh failed`);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
accessToken = refreshed.accessToken;
|
|
148
174
|
response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
|
|
149
175
|
method: "POST",
|
|
150
176
|
headers: {
|