@vellumai/cli 0.8.10-dev.202606110240.ef9212e → 0.8.10-dev.202606110422.8c0e9aa

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.
@@ -64,3 +64,16 @@ export function resolveConfigDir(
64
64
  }
65
65
  return path.join(xdgConfigHome, `vellum-${vellumEnv}`);
66
66
  }
67
+
68
+ /**
69
+ * The on-disk location of an assistant's guardian token, given an already
70
+ * resolved config dir. The single source of truth for this path so the CLI
71
+ * writer and every host-seam reader agree — a divergence here is what leaves a
72
+ * freshly leased token unreadable and bricks the connect.
73
+ */
74
+ export function guardianTokenPath(
75
+ configDir: string,
76
+ assistantId: string,
77
+ ): string {
78
+ return path.join(configDir, "assistants", assistantId, "guardian-token.json");
79
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
3
 
4
+ import { guardianTokenPath } from "./config";
5
5
  import type { CliInvocation } from "./util";
6
6
 
7
7
  const GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS = 15_000;
@@ -40,7 +40,7 @@ export function getGuardianAccessToken(
40
40
  return Promise.resolve({ ok: false, status: 403, error: "Forbidden" });
41
41
  }
42
42
 
43
- const tokenPath = path.join(configDir, "assistants", assistantId, "guardian-token.json");
43
+ const tokenPath = guardianTokenPath(configDir, assistantId);
44
44
 
45
45
  let raw: string;
46
46
  try {
@@ -14,7 +14,7 @@ export {
14
14
  resolveDevCliInvocation,
15
15
  } from "./util";
16
16
  export type { CliInvocation } from "./util";
17
- export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir } from "./config";
17
+ export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir, guardianTokenPath } from "./config";
18
18
  export type { LocalEndpointConfig } from "./config";
19
19
  export { defaultEnvironmentFilePath, readDefaultEnvironment, resolveEnvironmentName } from "./environment";
20
20
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.10-dev.202606110240.ef9212e",
3
+ "version": "0.8.10-dev.202606110422.8c0e9aa",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,8 @@ import {
11
11
  import { tmpdir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
13
 
14
+ import { guardianTokenPath, resolveConfigDir } from "@vellumai/local-mode";
15
+
14
16
  import {
15
17
  getOrCreatePersistedDeviceId,
16
18
  guardianTokenDueForRenewal,
@@ -20,6 +22,8 @@ import {
20
22
  seedGuardianTokenFromSiblingEnv,
21
23
  type GuardianTokenData,
22
24
  } from "../lib/guardian-token.js";
25
+ import { getConfigDir } from "../lib/environments/paths.js";
26
+ import { getCurrentEnvironment } from "../lib/environments/resolve.js";
23
27
 
24
28
  function makeTokenData(suffix: string): GuardianTokenData {
25
29
  const now = new Date().toISOString();
@@ -473,3 +477,78 @@ describe("guardianTokenDueForRenewal", () => {
473
477
  ).toBe(false);
474
478
  });
475
479
  });
480
+
481
+ // Drift guard between the guardian-token WRITE path (CLI: getGuardianTokenPath
482
+ // → getConfigDir(getCurrentEnvironment())) and the READ path used by every
483
+ // host-seam reader (getGuardianAccessToken → resolveConfigDir(env) from
484
+ // @vellumai/local-mode). A divergence here writes a freshly leased token where
485
+ // the connect can't read it, bricking local-assistant connect. saveGuardianToken
486
+ // already resolves through the shared resolver; this asserts the two resolvers
487
+ // stay in lockstep so a future change to either can't silently relocate tokens.
488
+ describe("guardian-token path resolver parity (CLI ↔ shared)", () => {
489
+ let tempHome: string;
490
+ let savedXdg: string | undefined;
491
+ let savedEnv: string | undefined;
492
+
493
+ beforeEach(() => {
494
+ savedXdg = process.env.XDG_CONFIG_HOME;
495
+ savedEnv = process.env.VELLUM_ENVIRONMENT;
496
+ tempHome = mkdtempSync(join(tmpdir(), "cli-guardian-parity-test-"));
497
+ process.env.XDG_CONFIG_HOME = tempHome;
498
+ delete process.env.VELLUM_ENVIRONMENT;
499
+ });
500
+
501
+ afterEach(() => {
502
+ if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
503
+ else process.env.XDG_CONFIG_HOME = savedXdg;
504
+ if (savedEnv === undefined) delete process.env.VELLUM_ENVIRONMENT;
505
+ else process.env.VELLUM_ENVIRONMENT = savedEnv;
506
+ rmSync(tempHome, { recursive: true, force: true });
507
+ });
508
+
509
+ // The CLI's own resolver and the shared @vellumai/local-mode resolver must
510
+ // agree on the config dir for every environment source.
511
+ const expectResolversAgree = () => {
512
+ expect(getConfigDir(getCurrentEnvironment())).toBe(
513
+ resolveConfigDir(process.env),
514
+ );
515
+ };
516
+
517
+ test("unset → production: resolvers agree and saveGuardianToken lands there", () => {
518
+ expectResolversAgree();
519
+ saveGuardianToken("alpha", makeTokenData("prod"));
520
+ expect(
521
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
522
+ ).toBe(true);
523
+ });
524
+
525
+ test("VELLUM_ENVIRONMENT=dev: resolvers agree and token lands there", () => {
526
+ process.env.VELLUM_ENVIRONMENT = "dev";
527
+ expectResolversAgree();
528
+ saveGuardianToken("alpha", makeTokenData("dev"));
529
+ expect(
530
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
531
+ ).toBe(true);
532
+ });
533
+
534
+ test("VELLUM_ENVIRONMENT=local: resolvers agree and token lands there", () => {
535
+ process.env.VELLUM_ENVIRONMENT = "local";
536
+ expectResolversAgree();
537
+ saveGuardianToken("alpha", makeTokenData("local"));
538
+ expect(
539
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
540
+ ).toBe(true);
541
+ });
542
+
543
+ test("persisted default env file (no VELLUM_ENVIRONMENT): resolvers agree", () => {
544
+ // Mirror `vellum env set dev`: the default-env file lives at the fixed,
545
+ // env-agnostic path $XDG_CONFIG_HOME/vellum/environment.
546
+ mkdirSync(join(tempHome, "vellum"), { recursive: true });
547
+ writeFileSync(join(tempHome, "vellum", "environment"), "dev\n");
548
+ expectResolversAgree();
549
+ saveGuardianToken("alpha", makeTokenData("default-dev"));
550
+ expect(
551
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
552
+ ).toBe(true);
553
+ });
554
+ });
@@ -58,10 +58,27 @@ mock.module("../lib/docker.js", () => ({
58
58
  const seedGuardianTokenFromSiblingEnvMock = mock<
59
59
  typeof guardianToken.seedGuardianTokenFromSiblingEnv
60
60
  >(() => false);
61
+ // Default: a token exists, so the re-provision recovery path is skipped. Tests
62
+ // that exercise recovery override loadGuardianToken to return null.
63
+ const loadGuardianTokenMock = mock<typeof guardianToken.loadGuardianToken>(
64
+ () => ({ accessToken: "existing" }) as ReturnType<
65
+ typeof guardianToken.loadGuardianToken
66
+ >,
67
+ );
68
+ const resetGuardianBootstrapMock = mock<
69
+ typeof guardianToken.resetGuardianBootstrap
70
+ >(async () => {});
71
+ const leaseGuardianTokenMock = mock<typeof guardianToken.leaseGuardianToken>(
72
+ async () =>
73
+ ({}) as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>,
74
+ );
61
75
 
62
76
  mock.module("../lib/guardian-token.js", () => ({
63
77
  ...realGuardianToken,
64
78
  seedGuardianTokenFromSiblingEnv: seedGuardianTokenFromSiblingEnvMock,
79
+ loadGuardianToken: loadGuardianTokenMock,
80
+ resetGuardianBootstrap: resetGuardianBootstrapMock,
81
+ leaseGuardianToken: leaseGuardianTokenMock,
65
82
  }));
66
83
 
67
84
  const resolveProcessStateMock = mock<typeof processLib.resolveProcessState>(
@@ -169,6 +186,16 @@ beforeEach(() => {
169
186
  startGatewayMock.mockResolvedValue("http://127.0.0.1:7830");
170
187
  seedGuardianTokenFromSiblingEnvMock.mockReset();
171
188
  seedGuardianTokenFromSiblingEnvMock.mockReturnValue(false);
189
+ loadGuardianTokenMock.mockReset();
190
+ loadGuardianTokenMock.mockReturnValue({ accessToken: "existing" } as ReturnType<
191
+ typeof guardianToken.loadGuardianToken
192
+ >);
193
+ resetGuardianBootstrapMock.mockReset();
194
+ resetGuardianBootstrapMock.mockResolvedValue(undefined);
195
+ leaseGuardianTokenMock.mockReset();
196
+ leaseGuardianTokenMock.mockResolvedValue(
197
+ {} as Awaited<ReturnType<typeof guardianToken.leaseGuardianToken>>,
198
+ );
172
199
  maybeStartNgrokTunnelMock.mockReset();
173
200
  maybeStartNgrokTunnelMock.mockResolvedValue(null);
174
201
  });
@@ -212,4 +239,45 @@ describe("vellum wake", () => {
212
239
  },
213
240
  );
214
241
  });
242
+
243
+ test("re-provisions the guardian token when missing and --repair-guardian is passed", async () => {
244
+ process.argv = ["bun", "vellum", "wake", "--repair-guardian", "local-assistant"];
245
+ loadGuardianTokenMock.mockReturnValue(null);
246
+
247
+ await wake();
248
+
249
+ // Resets the gateway's spent bootstrap state, then re-leases against the
250
+ // loopback gateway with the lockfile's bootstrap secret.
251
+ expect(resetGuardianBootstrapMock).toHaveBeenCalledWith(
252
+ "http://127.0.0.1:7830",
253
+ "generated-bootstrap-secret",
254
+ );
255
+ expect(leaseGuardianTokenMock).toHaveBeenCalledWith(
256
+ "http://127.0.0.1:7830",
257
+ "local-assistant",
258
+ "generated-bootstrap-secret",
259
+ );
260
+ });
261
+
262
+ test("does NOT re-provision without --repair-guardian, even when the token is missing", async () => {
263
+ // The automatic connect-repair path spawns `wake <id>` with no flags. A
264
+ // re-lease here would revoke other device-bound tokens (other tabs / local
265
+ // clients), so it must never run from auto-repair.
266
+ process.argv = ["bun", "vellum", "wake", "local-assistant"];
267
+ loadGuardianTokenMock.mockReturnValue(null);
268
+
269
+ await wake();
270
+
271
+ expect(resetGuardianBootstrapMock).not.toHaveBeenCalled();
272
+ expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
273
+ });
274
+
275
+ test("skips re-provision when a guardian token already exists", async () => {
276
+ process.argv = ["bun", "vellum", "wake", "--repair-guardian", "local-assistant"];
277
+ // loadGuardianToken returns a token by default — recovery must not run.
278
+ await wake();
279
+
280
+ expect(resetGuardianBootstrapMock).not.toHaveBeenCalled();
281
+ expect(leaseGuardianTokenMock).not.toHaveBeenCalled();
282
+ });
215
283
  });
@@ -7,7 +7,12 @@ import {
7
7
  saveAssistantEntry,
8
8
  } from "../lib/assistant-config.js";
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
- import { seedGuardianTokenFromSiblingEnv } from "../lib/guardian-token.js";
10
+ import {
11
+ leaseGuardianToken,
12
+ loadGuardianToken,
13
+ resetGuardianBootstrap,
14
+ seedGuardianTokenFromSiblingEnv,
15
+ } from "../lib/guardian-token.js";
11
16
  import { resolveProcessState, stopProcessByPidFile } from "../lib/process";
12
17
  import {
13
18
  generateLocalSigningKey,
@@ -37,11 +42,23 @@ export async function wake(): Promise<void> {
37
42
  console.log(
38
43
  " --foreground Run assistant in foreground with logs printed to terminal",
39
44
  );
45
+ console.log(
46
+ " --repair-guardian Re-provision the guardian token if missing (resets the\n" +
47
+ " gateway bootstrap and re-leases — REVOKES other device-bound\n" +
48
+ " tokens, so only use deliberately, never from auto-repair)",
49
+ );
40
50
  process.exit(0);
41
51
  }
42
52
 
43
53
  const watch = args.includes("--watch");
44
54
  const foreground = args.includes("--foreground");
55
+ // Re-leasing the guardian token calls guardian/init, which revokes every
56
+ // other device-bound token (other tabs, other local clients on this machine).
57
+ // Gate it behind an explicit flag so the automatic connect-repair path
58
+ // (`runWake` spawns `wake <id>` with no flags) can never revoke a live session
59
+ // — it only ever restarts + sibling-seeds. A genuine spent-bootstrap brick is
60
+ // recovered deliberately via `vellum wake <id> --repair-guardian`.
61
+ const repairGuardian = args.includes("--repair-guardian");
45
62
  const nameArg = args.find((a) => !a.startsWith("-"));
46
63
  const entry = resolveTargetAssistant(nameArg);
47
64
 
@@ -221,6 +238,37 @@ export async function wake(): Promise<void> {
221
238
  console.log(" Seeded guardian token from sibling environment.");
222
239
  }
223
240
 
241
+ // Last-resort recovery (explicit `--repair-guardian` only): if no guardian
242
+ // token exists for this env even after sibling seeding, re-provision one. The
243
+ // single-use bootstrap secret may already be spent — a prior connect can
244
+ // lease a token that's then lost, or the gateway marks the secret consumed
245
+ // before the client persists it — which otherwise bricks connect into a
246
+ // 401 → auth-rate-limit → 429 cascade with no path back short of retire+hatch.
247
+ // Reset the gateway's bootstrap lock+consumed state (loopback-only, authorized
248
+ // by the lockfile secret — mirrors the macOS client's forceReBootstrap), then
249
+ // re-lease. Gated behind the flag because the re-lease revokes other
250
+ // device-bound tokens; it must never run from the automatic repair path.
251
+ if (repairGuardian && !loadGuardianToken(entry.assistantId)) {
252
+ const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
253
+ const maxAttempts = 3;
254
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
255
+ try {
256
+ await resetGuardianBootstrap(loopbackUrl, bootstrapSecret);
257
+ await leaseGuardianToken(loopbackUrl, entry.assistantId, bootstrapSecret);
258
+ console.log(" Re-provisioned guardian token.");
259
+ break;
260
+ } catch (err) {
261
+ if (attempt < maxAttempts) {
262
+ await new Promise((r) => setTimeout(r, 2000 * 2 ** (attempt - 1)));
263
+ } else {
264
+ console.warn(
265
+ ` Guardian token re-provision failed after ${maxAttempts} attempts: ${err}`,
266
+ );
267
+ }
268
+ }
269
+ }
270
+ }
271
+
224
272
  // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
225
273
  const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
226
274
  const ngrokChild = await maybeStartNgrokTunnel(
@@ -17,6 +17,10 @@ import { platform } from "os";
17
17
  import { dirname, join } from "path";
18
18
 
19
19
  import { SEEDS } from "@vellumai/environments";
20
+ import {
21
+ guardianTokenPath,
22
+ resolveConfigDir,
23
+ } from "@vellumai/local-mode";
20
24
 
21
25
  import { getConfigDir } from "./environments/paths.js";
22
26
  import { getCurrentEnvironment } from "./environments/resolve.js";
@@ -38,12 +42,12 @@ export interface GuardianTokenData {
38
42
  }
39
43
 
40
44
  function getGuardianTokenPath(assistantId: string): string {
41
- return join(
42
- getConfigDir(getCurrentEnvironment()),
43
- "assistants",
44
- assistantId,
45
- "guardian-token.json",
46
- );
45
+ // Resolve via the shared @vellumai/local-mode resolver — the same one every
46
+ // host-seam reader (`getGuardianAccessToken`) uses — so the token is always
47
+ // written where it's read. Must stay in lockstep with `getConfigDir(
48
+ // getCurrentEnvironment())`; the parity test in guardian-token.test.ts guards
49
+ // against drift.
50
+ return guardianTokenPath(resolveConfigDir(process.env), assistantId);
47
51
  }
48
52
 
49
53
  /**
@@ -430,6 +434,37 @@ export async function leaseGuardianToken(
430
434
  return tokenData;
431
435
  }
432
436
 
437
+ /**
438
+ * Clear the gateway's guardian-init lock + consumed-secret state via
439
+ * `POST /v1/guardian/reset-bootstrap`, so a spent single-use bootstrap secret
440
+ * can be used again by a subsequent `leaseGuardianToken`. Loopback-only on the
441
+ * gateway; when bootstrap secrets are configured the gateway requires a
442
+ * matching `x-bootstrap-secret`. Mirrors the macOS client's `forceReBootstrap`
443
+ * recovery. Throws on a non-OK response.
444
+ */
445
+ export async function resetGuardianBootstrap(
446
+ gatewayUrl: string,
447
+ bootstrapSecret?: string,
448
+ ): Promise<void> {
449
+ const headers: Record<string, string> = {
450
+ "Content-Type": "application/json",
451
+ };
452
+ if (bootstrapSecret) {
453
+ headers["x-bootstrap-secret"] = bootstrapSecret;
454
+ }
455
+ const response = await fetch(`${gatewayUrl}/v1/guardian/reset-bootstrap`, {
456
+ method: "POST",
457
+ headers,
458
+ body: JSON.stringify({}),
459
+ });
460
+ if (!response.ok) {
461
+ const body = await response.text();
462
+ throw new Error(
463
+ `guardian/reset-bootstrap failed (${response.status}): ${body}`,
464
+ );
465
+ }
466
+ }
467
+
433
468
  /**
434
469
  * Copy a guardian token from a sibling environment's config directory into
435
470
  * the current environment's dir when the current one is missing it.
@@ -1,3 +1,5 @@
1
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
2
+
1
3
  export const HEALTH_CHECK_TIMEOUT_MS = 1500;
2
4
 
3
5
  interface HealthResponse {
@@ -44,7 +46,7 @@ export async function checkManagedHealth(
44
46
  HEALTH_CHECK_TIMEOUT_MS,
45
47
  );
46
48
 
47
- const response = await fetch(url, {
49
+ const response = await loopbackSafeFetch(url, {
48
50
  signal: controller.signal,
49
51
  headers,
50
52
  });
@@ -105,7 +107,7 @@ export async function fetchManagedPs(
105
107
  const controller = new AbortController();
106
108
  const timeoutId = setTimeout(() => controller.abort(), 5000);
107
109
 
108
- const response = await fetch(psUrl, {
110
+ const response = await loopbackSafeFetch(psUrl, {
109
111
  signal: controller.signal,
110
112
  headers,
111
113
  });
@@ -144,7 +146,7 @@ async function fetchLegacyConnectionStatus(
144
146
  const controller = new AbortController();
145
147
  const timeoutId = setTimeout(() => controller.abort(), 5000);
146
148
 
147
- const response = await fetch(url, {
149
+ const response = await loopbackSafeFetch(url, {
148
150
  method: "POST",
149
151
  signal: controller.signal,
150
152
  headers,
@@ -188,7 +190,7 @@ export async function checkHealth(
188
190
  headers["Authorization"] = `Bearer ${bearerToken}`;
189
191
  }
190
192
 
191
- const response = await fetch(url, {
193
+ const response = await loopbackSafeFetch(url, {
192
194
  signal: controller.signal,
193
195
  headers,
194
196
  });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bun's fetch pools sockets even when the server responds `Connection:
3
+ * close` (e.g. the Werkzeug dev platform on localhost), so the next request
4
+ * to the same origin is written to a dead socket and hangs until its abort
5
+ * timeout fires. Disable keepalive for loopback targets to force a fresh
6
+ * connection per request; remote hosts are unaffected.
7
+ */
8
+
9
+ function isLoopbackUrl(url: string): boolean {
10
+ try {
11
+ // WHATWG URL canonicalizes hostnames, so IPv6 loopback is always "[::1]".
12
+ const h = new URL(url).hostname;
13
+ return (
14
+ h === "localhost" || h === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(h)
15
+ );
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export function loopbackSafeFetch(
22
+ url: string,
23
+ init?: RequestInit,
24
+ ): Promise<Response> {
25
+ return isLoopbackUrl(url)
26
+ ? fetch(url, { ...init, keepalive: false })
27
+ : fetch(url, init);
28
+ }
@@ -11,6 +11,7 @@ import { join, dirname } from "path";
11
11
  import { getLockfilePlatformBaseUrl } from "./assistant-config.js";
12
12
  import { getConfigDir } from "./environments/paths.js";
13
13
  import { getCurrentEnvironment } from "./environments/resolve.js";
14
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
14
15
 
15
16
  function getPlatformTokenPath(): string {
16
17
  return join(getConfigDir(getCurrentEnvironment()), "platform-token");
@@ -229,7 +230,7 @@ export async function ensureSelfHostedLocalRegistration(
229
230
  body.public_ingress_url = publicBaseUrl;
230
231
  }
231
232
 
232
- const response = await fetch(
233
+ const response = await loopbackSafeFetch(
233
234
  `${resolvedUrl}/v1/assistants/self-hosted-local/ensure-registration/`,
234
235
  {
235
236
  method: "POST",
@@ -292,7 +293,7 @@ export async function reprovisionAssistantApiKey(
292
293
  body.assistant_version = assistantVersion;
293
294
  }
294
295
 
295
- const response = await fetch(
296
+ const response = await loopbackSafeFetch(
296
297
  `${resolvedUrl}/v1/assistants/self-hosted-local/reprovision-api-key/`,
297
298
  {
298
299
  method: "POST",
@@ -358,7 +359,7 @@ export async function readGatewayCredential(
358
359
  headers["Authorization"] = `Bearer ${bearerToken}`;
359
360
  }
360
361
 
361
- const response = await fetch(`${gatewayUrl}/v1/secrets/read`, {
362
+ const response = await loopbackSafeFetch(`${gatewayUrl}/v1/secrets/read`, {
362
363
  method: "POST",
363
364
  headers,
364
365
  body: JSON.stringify({ type: "credential", name, reveal: true }),
@@ -416,7 +417,7 @@ async function injectGatewayCredential(
416
417
  headers["Authorization"] = `Bearer ${bearerToken}`;
417
418
  }
418
419
 
419
- const response = await fetch(`${gatewayUrl}/v1/secrets`, {
420
+ const response = await loopbackSafeFetch(`${gatewayUrl}/v1/secrets`, {
420
421
  method: "POST",
421
422
  headers,
422
423
  body: JSON.stringify({ type: "credential", name, value }),
@@ -486,7 +487,7 @@ export async function hatchAssistant(
486
487
  const resolvedUrl = platformUrl || getPlatformUrl();
487
488
  const url = `${resolvedUrl}/v1/assistants/hatch/`;
488
489
 
489
- const response = await fetch(url, {
490
+ const response = await loopbackSafeFetch(url, {
490
491
  method: "POST",
491
492
  headers: await authHeaders(token, platformUrl),
492
493
  body: JSON.stringify({}),
@@ -545,7 +546,7 @@ export async function checkExistingPlatformAssistant(
545
546
  );
546
547
 
547
548
  try {
548
- const response = await fetch(url, {
549
+ const response = await loopbackSafeFetch(url, {
549
550
  signal: controller.signal,
550
551
  headers: await authHeaders(token, platformUrl),
551
552
  });
@@ -583,7 +584,7 @@ export async function fetchPlatformAssistants(
583
584
  );
584
585
 
585
586
  try {
586
- const response = await fetch(url, {
587
+ const response = await loopbackSafeFetch(url, {
587
588
  signal: controller.signal,
588
589
  headers: await authHeaders(token, platformUrl),
589
590
  });
@@ -624,7 +625,7 @@ export async function fetchOrganizationId(
624
625
  );
625
626
 
626
627
  try {
627
- const response = await fetch(url, {
628
+ const response = await loopbackSafeFetch(url, {
628
629
  signal: controller.signal,
629
630
  headers: { ...tokenAuthHeader(token) },
630
631
  });
@@ -671,7 +672,7 @@ export async function fetchCurrentUser(
671
672
  );
672
673
 
673
674
  try {
674
- const response = await fetch(url, {
675
+ const response = await loopbackSafeFetch(url, {
675
676
  signal: controller.signal,
676
677
  headers: { "X-Session-Token": token },
677
678
  });
@@ -706,11 +707,14 @@ export async function rollbackPlatformAssistant(
706
707
  platformUrl?: string,
707
708
  ): Promise<{ detail: string; version: string | null }> {
708
709
  const resolvedUrl = platformUrl || getPlatformUrl();
709
- const response = await fetch(`${resolvedUrl}/v1/assistants/rollback/`, {
710
- method: "POST",
711
- headers: await authHeaders(token, platformUrl),
712
- body: JSON.stringify(version ? { version } : {}),
713
- });
710
+ const response = await loopbackSafeFetch(
711
+ `${resolvedUrl}/v1/assistants/rollback/`,
712
+ {
713
+ method: "POST",
714
+ headers: await authHeaders(token, platformUrl),
715
+ body: JSON.stringify(version ? { version } : {}),
716
+ },
717
+ );
714
718
 
715
719
  const body = (await response.json().catch(() => ({}))) as {
716
720
  detail?: string;
@@ -744,7 +748,7 @@ export async function platformUploadToSignedUrl(
744
748
  uploadUrl: string,
745
749
  bundleData: Uint8Array<ArrayBuffer>,
746
750
  ): Promise<void> {
747
- const response = await fetch(uploadUrl, {
751
+ const response = await loopbackSafeFetch(uploadUrl, {
748
752
  method: "PUT",
749
753
  headers: {
750
754
  "Content-Type": "application/octet-stream",
@@ -766,7 +770,7 @@ export async function platformImportPreflightFromGcs(
766
770
  platformUrl?: string,
767
771
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
768
772
  const resolvedUrl = platformUrl || getPlatformUrl();
769
- const response = await fetch(
773
+ const response = await loopbackSafeFetch(
770
774
  `${resolvedUrl}/v1/migrations/import-preflight-from-gcs/`,
771
775
  {
772
776
  method: "POST",
@@ -789,7 +793,7 @@ export async function platformImportBundleFromGcs(
789
793
  platformUrl?: string,
790
794
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
791
795
  const resolvedUrl = platformUrl || getPlatformUrl();
792
- const response = await fetch(
796
+ const response = await loopbackSafeFetch(
793
797
  `${resolvedUrl}/v1/migrations/import-from-gcs/`,
794
798
  {
795
799
  method: "POST",
@@ -970,7 +974,7 @@ export async function platformRequestSignedUrl(
970
974
  }
971
975
 
972
976
  const doRequest = async (): Promise<Response> =>
973
- fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
977
+ loopbackSafeFetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
974
978
  method: "POST",
975
979
  headers: await authHeaders(token, platformUrl),
976
980
  body: JSON.stringify(body),
@@ -1040,9 +1044,12 @@ export async function platformPollJobStatus(
1040
1044
  platformUrl?: string,
1041
1045
  ): Promise<UnifiedJobStatus> {
1042
1046
  const resolvedUrl = platformUrl || getPlatformUrl();
1043
- const response = await fetch(`${resolvedUrl}/v1/migrations/jobs/${jobId}/`, {
1044
- headers: await authHeaders(token, platformUrl),
1045
- });
1047
+ const response = await loopbackSafeFetch(
1048
+ `${resolvedUrl}/v1/migrations/jobs/${jobId}/`,
1049
+ {
1050
+ headers: await authHeaders(token, platformUrl),
1051
+ },
1052
+ );
1046
1053
 
1047
1054
  if (response.status === 404) {
1048
1055
  throw new Error("Migration job not found");