@vellumai/cli 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { resolveRuntimeMigrationUrl } from "../runtime-url.js";
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 `Authorization: Bearer <sessionToken>` and the org id is
30
- * sent via the `X-Vellum-Org-Id` header.
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 session token + org id header.
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
- headers["Authorization"] ??= `Bearer ${this.token}`;
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["X-Vellum-Org-Id"] ??= this.orgId;
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
- }
@@ -9,7 +9,10 @@ import {
9
9
  import { homedir } from "os";
10
10
  import { dirname, join } from "path";
11
11
 
12
- import { loadGuardianToken, leaseGuardianToken } from "./guardian-token.js";
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
- /** Obtain a valid guardian access token (cached or fresh lease) */
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
- forceRefresh?: boolean,
33
- ): Promise<string> {
34
- if (!forceRefresh) {
35
- const tokenData = loadGuardianToken(assistantId);
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 freshToken = await leaseGuardianToken(runtimeUrl, assistantId);
41
- return freshToken.accessToken;
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 fresh token on 401 — the cached token may be stale
70
- // after a container restart that generated a new gateway signing key.
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
- accessToken = await getGuardianAccessToken(runtimeUrl, assistantId, true);
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 fresh token on 401 — the cached token may be stale
145
- // after a container restart that generated a new gateway signing key.
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
- accessToken = await getGuardianAccessToken(runtimeUrl, assistantId, true);
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: {