@vellumai/cli 0.8.10-staging.1 → 0.8.11-staging.1

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 (48) hide show
  1. package/AGENTS.md +2 -0
  2. package/node_modules/@vellumai/local-mode/src/config.ts +13 -0
  3. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +2 -2
  4. package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +20 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
  7. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +169 -0
  8. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -4
  9. package/package.json +1 -1
  10. package/src/__tests__/confirm.test.ts +85 -0
  11. package/src/__tests__/device-id.test.ts +167 -0
  12. package/src/__tests__/guardian-token.test.ts +79 -0
  13. package/src/__tests__/helpers/env.ts +19 -0
  14. package/src/__tests__/statefulset.test.ts +149 -0
  15. package/src/__tests__/upgrade-replay-env.test.ts +165 -0
  16. package/src/__tests__/wake.test.ts +68 -0
  17. package/src/commands/backup.ts +3 -2
  18. package/src/commands/client.ts +22 -5
  19. package/src/commands/confirm.ts +144 -0
  20. package/src/commands/connect.ts +1 -1
  21. package/src/commands/devices.ts +4 -3
  22. package/src/commands/hatch.ts +16 -1
  23. package/src/commands/pair.ts +3 -2
  24. package/src/commands/restore.ts +3 -2
  25. package/src/commands/retire.ts +2 -1
  26. package/src/commands/roadmap.ts +2 -1
  27. package/src/commands/rollback.ts +9 -37
  28. package/src/commands/unpair.ts +1 -1
  29. package/src/commands/upgrade.ts +13 -44
  30. package/src/commands/wake.ts +49 -1
  31. package/src/index.ts +11 -4
  32. package/src/lib/assistant-client.ts +3 -2
  33. package/src/lib/backup-ops.ts +5 -4
  34. package/src/lib/device-id.ts +85 -0
  35. package/src/lib/docker.ts +19 -3
  36. package/src/lib/guardian-token.ts +44 -8
  37. package/src/lib/hatch-local.ts +2 -1
  38. package/src/lib/health-check.ts +6 -4
  39. package/src/lib/http-client.ts +3 -1
  40. package/src/lib/local-runtime-client.ts +5 -4
  41. package/src/lib/local.ts +1 -0
  42. package/src/lib/loopback-fetch.ts +28 -0
  43. package/src/lib/ngrok.ts +2 -1
  44. package/src/lib/platform-client.ts +28 -21
  45. package/src/lib/platform-releases.ts +3 -2
  46. package/src/lib/statefulset.ts +43 -0
  47. package/src/lib/terminal-client.ts +6 -5
  48. package/src/lib/upgrade-lifecycle.ts +114 -53
@@ -19,6 +19,7 @@ import {
19
19
  refreshGuardianToken,
20
20
  guardianTokenDueForRenewal,
21
21
  } from "./guardian-token.js";
22
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
22
23
 
23
24
  const DEFAULT_TIMEOUT_MS = 30_000;
24
25
  const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
@@ -203,7 +204,7 @@ export class AssistantClient {
203
204
  const doFetch = (): Promise<Response> => {
204
205
  const headers = buildHeaders();
205
206
  if (opts?.signal) {
206
- return fetch(url, {
207
+ return loopbackSafeFetch(url, {
207
208
  method,
208
209
  headers,
209
210
  body: jsonBody,
@@ -213,7 +214,7 @@ export class AssistantClient {
213
214
  const timeout = opts?.timeout ?? DEFAULT_TIMEOUT_MS;
214
215
  const controller = new AbortController();
215
216
  const timeoutId = setTimeout(() => controller.abort(), timeout);
216
- return fetch(url, {
217
+ return loopbackSafeFetch(url, {
217
218
  method,
218
219
  headers,
219
220
  body: jsonBody,
@@ -13,6 +13,7 @@ import {
13
13
  loadGuardianToken,
14
14
  refreshGuardianToken,
15
15
  } from "./guardian-token.js";
16
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
16
17
 
17
18
  /** Default backup directory following XDG convention */
18
19
  export function getBackupsDir(): string {
@@ -66,7 +67,7 @@ export async function createBackup(
66
67
  return null;
67
68
  }
68
69
 
69
- let response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
70
+ let response = await loopbackSafeFetch(`${runtimeUrl}/v1/migrations/export`, {
70
71
  method: "POST",
71
72
  headers: {
72
73
  Authorization: `Bearer ${accessToken}`,
@@ -87,7 +88,7 @@ export async function createBackup(
87
88
  return null;
88
89
  }
89
90
  accessToken = refreshed.accessToken;
90
- response = await fetch(`${runtimeUrl}/v1/migrations/export`, {
91
+ response = await loopbackSafeFetch(`${runtimeUrl}/v1/migrations/export`, {
91
92
  method: "POST",
92
93
  headers: {
93
94
  Authorization: `Bearer ${accessToken}`,
@@ -152,7 +153,7 @@ export async function restoreBackup(
152
153
  return false;
153
154
  }
154
155
 
155
- let response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
156
+ let response = await loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import`, {
156
157
  method: "POST",
157
158
  headers: {
158
159
  Authorization: `Bearer ${accessToken}`,
@@ -171,7 +172,7 @@ export async function restoreBackup(
171
172
  return false;
172
173
  }
173
174
  accessToken = refreshed.accessToken;
174
- response = await fetch(`${runtimeUrl}/v1/migrations/import`, {
175
+ response = await loopbackSafeFetch(`${runtimeUrl}/v1/migrations/import`, {
175
176
  method: "POST",
176
177
  headers: {
177
178
  Authorization: `Bearer ${accessToken}`,
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Host device ID resolver. Resolution order: `VELLUM_DEVICE_ID` env var,
3
+ * then `device.json`. Production uses the machine-wide shared
4
+ * `~/.vellum/device.json`, matching Electron (`apps/macos/src/main/device-id.ts`)
5
+ * and Swift (`VellumPaths.deviceIdFile`); non-production uses
6
+ * `<configDir>/device.json`.
7
+ *
8
+ * Not to be confused with `guardian-token.ts`'s salted-hash Guardian
9
+ * identity (`computeDeviceId` / `getOrCreatePersistedDeviceId`) — do not
10
+ * merge the two.
11
+ */
12
+
13
+ import { randomUUID } from "crypto";
14
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
15
+ import { homedir } from "os";
16
+ import { join } from "path";
17
+
18
+ import { getConfigDir } from "./environments/paths.js";
19
+ import { getCurrentEnvironment } from "./environments/resolve.js";
20
+
21
+ let cached: string | undefined;
22
+
23
+ function resolveDeviceIdPaths(): { dir: string; file: string } {
24
+ const env = getCurrentEnvironment();
25
+ const dir =
26
+ env.name === "production"
27
+ ? join(homedir(), ".vellum")
28
+ : getConfigDir(env);
29
+ return { dir, file: join(dir, "device.json") };
30
+ }
31
+
32
+ /**
33
+ * Get the stable device ID for this host machine, creating and persisting
34
+ * one in `device.json` if absent. `VELLUM_DEVICE_ID` takes precedence over
35
+ * any file. Never throws: on write failure the generated UUID is still
36
+ * cached and returned for the process lifetime.
37
+ */
38
+ export function getOrCreateHostDeviceId(): string {
39
+ if (cached !== undefined) {
40
+ return cached;
41
+ }
42
+
43
+ const fromEnv = process.env.VELLUM_DEVICE_ID?.trim();
44
+ if (fromEnv) {
45
+ cached = fromEnv;
46
+ return cached;
47
+ }
48
+
49
+ const { dir, file } = resolveDeviceIdPaths();
50
+
51
+ // Preserve unrelated fields from any existing JSON object.
52
+ let existing: Record<string, unknown> = {};
53
+ try {
54
+ const raw: unknown = JSON.parse(readFileSync(file, "utf-8"));
55
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
56
+ existing = raw as Record<string, unknown>;
57
+ }
58
+ } catch {
59
+ // Missing, unreadable, or malformed — start fresh.
60
+ }
61
+
62
+ if (typeof existing.deviceId === "string" && existing.deviceId.length > 0) {
63
+ cached = existing.deviceId;
64
+ return cached;
65
+ }
66
+
67
+ const generated = randomUUID();
68
+ try {
69
+ mkdirSync(dir, { recursive: true });
70
+ existing.deviceId = generated;
71
+ writeFileSync(file, JSON.stringify(existing, null, 2) + "\n", {
72
+ mode: 0o644,
73
+ });
74
+ } catch {
75
+ // Write failure — use the generated ID in-memory only.
76
+ }
77
+
78
+ cached = generated;
79
+ return cached;
80
+ }
81
+
82
+ /** Reset the cached device ID. Used by tests to force re-resolution. */
83
+ export function resetHostDeviceIdCache(): void {
84
+ cached = undefined;
85
+ }
package/src/lib/docker.ts CHANGED
@@ -22,6 +22,7 @@ import type { AssistantEntry } from "./assistant-config";
22
22
  import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
23
23
  import { buildServiceRunArgs } from "./statefulset.js";
24
24
  import type { Species } from "./constants";
25
+ import { getOrCreateHostDeviceId } from "./device-id.js";
25
26
  import { getDefaultPorts } from "./environments/paths.js";
26
27
  import { getCurrentEnvironment } from "./environments/resolve.js";
27
28
  import { leaseGuardianToken } from "./guardian-token";
@@ -66,6 +67,7 @@ export {
66
67
  ASSISTANT_INTERNAL_PORT,
67
68
  GATEWAY_INTERNAL_PORT,
68
69
  } from "./environments/paths.js";
70
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
69
71
 
70
72
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
71
73
  export const DOCKER_READY_TIMEOUT_MS = 5 * 60 * 1000;
@@ -882,6 +884,8 @@ function startFileWatcher(opts: {
882
884
  signingKey?: string;
883
885
  bootstrapSecret?: string;
884
886
  cesServiceToken?: string;
887
+ extraAssistantEnv?: Record<string, string>;
888
+ extraGatewayEnv?: Record<string, string>;
885
889
  gatewayPort: number;
886
890
  imageTags: Record<ServiceName, string>;
887
891
  instanceName: string;
@@ -901,6 +905,8 @@ function startFileWatcher(opts: {
901
905
  signingKey: opts.signingKey,
902
906
  bootstrapSecret: opts.bootstrapSecret,
903
907
  cesServiceToken: opts.cesServiceToken,
908
+ extraAssistantEnv: opts.extraAssistantEnv,
909
+ extraGatewayEnv: opts.extraGatewayEnv,
904
910
  gatewayPort,
905
911
  imageTags,
906
912
  instanceName,
@@ -1323,8 +1329,16 @@ export async function hatchDocker(
1323
1329
  : ownSecret;
1324
1330
 
1325
1331
  emitProgress(4, 6, "Starting containers...");
1326
- const extraGatewayEnv =
1327
- Object.keys(flagEnvVars).length > 0 ? flagEnvVars : undefined;
1332
+ if (flagEnvVars.VELLUM_DISABLE_PLATFORM) {
1333
+ extraAssistantEnv.VELLUM_DISABLE_PLATFORM =
1334
+ flagEnvVars.VELLUM_DISABLE_PLATFORM;
1335
+ }
1336
+ const hostDeviceId = getOrCreateHostDeviceId();
1337
+ extraAssistantEnv.VELLUM_DEVICE_ID = hostDeviceId;
1338
+ const extraGatewayEnv = {
1339
+ ...flagEnvVars,
1340
+ VELLUM_DEVICE_ID: hostDeviceId,
1341
+ };
1328
1342
  await startContainers(
1329
1343
  {
1330
1344
  signingKey,
@@ -1426,6 +1440,8 @@ export async function hatchDocker(
1426
1440
  signingKey,
1427
1441
  bootstrapSecret,
1428
1442
  cesServiceToken,
1443
+ extraAssistantEnv,
1444
+ extraGatewayEnv,
1429
1445
  gatewayPort,
1430
1446
  imageTags,
1431
1447
  instanceName,
@@ -1515,7 +1531,7 @@ async function waitForGatewayAndLease(opts: {
1515
1531
 
1516
1532
  while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
1517
1533
  try {
1518
- const resp = await fetch(readyUrl, {
1534
+ const resp = await loopbackSafeFetch(readyUrl, {
1519
1535
  signal: AbortSignal.timeout(5000),
1520
1536
  });
1521
1537
  if (resp.ok) {
@@ -17,9 +17,14 @@ 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";
27
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
23
28
 
24
29
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
25
30
 
@@ -38,12 +43,12 @@ export interface GuardianTokenData {
38
43
  }
39
44
 
40
45
  function getGuardianTokenPath(assistantId: string): string {
41
- return join(
42
- getConfigDir(getCurrentEnvironment()),
43
- "assistants",
44
- assistantId,
45
- "guardian-token.json",
46
- );
46
+ // Resolve via the shared @vellumai/local-mode resolver — the same one every
47
+ // host-seam reader (`getGuardianAccessToken`) uses — so the token is always
48
+ // written where it's read. Must stay in lockstep with `getConfigDir(
49
+ // getCurrentEnvironment())`; the parity test in guardian-token.test.ts guards
50
+ // against drift.
51
+ return guardianTokenPath(resolveConfigDir(process.env), assistantId);
47
52
  }
48
53
 
49
54
  /**
@@ -342,7 +347,7 @@ export async function refreshGuardianToken(
342
347
 
343
348
  const tokenData = current ?? before;
344
349
 
345
- const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
350
+ const response = await loopbackSafeFetch(`${gatewayUrl}/v1/guardian/refresh`, {
346
351
  method: "POST",
347
352
  headers: {
348
353
  "Content-Type": "application/json",
@@ -402,7 +407,7 @@ export async function leaseGuardianToken(
402
407
  if (bootstrapSecret) {
403
408
  headers["x-bootstrap-secret"] = bootstrapSecret;
404
409
  }
405
- const response = await fetch(`${gatewayUrl}/v1/guardian/init`, {
410
+ const response = await loopbackSafeFetch(`${gatewayUrl}/v1/guardian/init`, {
406
411
  method: "POST",
407
412
  headers,
408
413
  body: JSON.stringify({ platform: "cli", deviceId }),
@@ -430,6 +435,37 @@ export async function leaseGuardianToken(
430
435
  return tokenData;
431
436
  }
432
437
 
438
+ /**
439
+ * Clear the gateway's guardian-init lock + consumed-secret state via
440
+ * `POST /v1/guardian/reset-bootstrap`, so a spent single-use bootstrap secret
441
+ * can be used again by a subsequent `leaseGuardianToken`. Loopback-only on the
442
+ * gateway; when bootstrap secrets are configured the gateway requires a
443
+ * matching `x-bootstrap-secret`. Mirrors the macOS client's `forceReBootstrap`
444
+ * recovery. Throws on a non-OK response.
445
+ */
446
+ export async function resetGuardianBootstrap(
447
+ gatewayUrl: string,
448
+ bootstrapSecret?: string,
449
+ ): Promise<void> {
450
+ const headers: Record<string, string> = {
451
+ "Content-Type": "application/json",
452
+ };
453
+ if (bootstrapSecret) {
454
+ headers["x-bootstrap-secret"] = bootstrapSecret;
455
+ }
456
+ const response = await fetch(`${gatewayUrl}/v1/guardian/reset-bootstrap`, {
457
+ method: "POST",
458
+ headers,
459
+ body: JSON.stringify({}),
460
+ });
461
+ if (!response.ok) {
462
+ const body = await response.text();
463
+ throw new Error(
464
+ `guardian/reset-bootstrap failed (${response.status}): ${body}`,
465
+ );
466
+ }
467
+ }
468
+
433
469
  /**
434
470
  * Copy a guardian token from a sibling environment's config directory into
435
471
  * the current environment's dir when the current one is missing it.
@@ -44,6 +44,7 @@ import {
44
44
  } from "./provider-secrets.js";
45
45
  import { logHatchNextSteps } from "./hatch-next-steps.js";
46
46
  import { checkProviderApiKey } from "./api-key-check.js";
47
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
47
48
 
48
49
  /**
49
50
  * Attempts to place a symlink at the given path pointing to cliBinary.
@@ -358,7 +359,7 @@ export async function hatchLocal(
358
359
  while (true) {
359
360
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
360
361
  try {
361
- const res = await fetch(healthUrl, {
362
+ const res = await loopbackSafeFetch(healthUrl, {
362
363
  signal: AbortSignal.timeout(3000),
363
364
  });
364
365
  if (res.ok) {
@@ -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
  });
@@ -1,3 +1,5 @@
1
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
2
+
1
3
  /**
2
4
  * Build the base URL for the daemon HTTP server.
3
5
  */
@@ -15,7 +17,7 @@ export async function httpHealthCheck(
15
17
  ): Promise<boolean> {
16
18
  try {
17
19
  const url = `${buildDaemonUrl(port)}/healthz`;
18
- const response = await fetch(url, {
20
+ const response = await loopbackSafeFetch(url, {
19
21
  signal: AbortSignal.timeout(timeoutMs),
20
22
  });
21
23
  return response.ok;
@@ -9,6 +9,7 @@ import {
9
9
  resolveRuntimeMigrationUrl,
10
10
  resolveRuntimeUrl,
11
11
  } from "./runtime-url.js";
12
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
12
13
 
13
14
  /**
14
15
  * Thrown when the local runtime returns 409 for an export/import request
@@ -122,7 +123,7 @@ export async function localRuntimeExportToGcs(
122
123
  body.description = params.description;
123
124
  }
124
125
 
125
- const response = await fetch(
126
+ const response = await loopbackSafeFetch(
126
127
  resolveRuntimeMigrationUrl(entry, "export-to-gcs"),
127
128
  {
128
129
  method: "POST",
@@ -166,7 +167,7 @@ export async function localRuntimeImportFromGcs(
166
167
  token: string,
167
168
  params: { bundleUrl: string },
168
169
  ): Promise<{ jobId: string }> {
169
- const response = await fetch(
170
+ const response = await loopbackSafeFetch(
170
171
  resolveRuntimeMigrationUrl(entry, "import-from-gcs"),
171
172
  {
172
173
  method: "POST",
@@ -211,7 +212,7 @@ export async function localRuntimePollJobStatus(
211
212
  token: string,
212
213
  jobId: string,
213
214
  ): Promise<UnifiedJobStatus> {
214
- const response = await fetch(
215
+ const response = await loopbackSafeFetch(
215
216
  resolveRuntimeMigrationUrl(entry, `jobs/${jobId}`),
216
217
  {
217
218
  headers: await migrationRequestHeaders(entry, token),
@@ -285,7 +286,7 @@ export async function localRuntimeIdentity(
285
286
  ): Promise<RuntimeIdentity> {
286
287
  const url = resolveRuntimeUrl(entry, "health");
287
288
  const doRequest = async (): Promise<Response> =>
288
- fetch(url, {
289
+ loopbackSafeFetch(url, {
289
290
  method: "GET",
290
291
  headers: await migrationRequestHeaders(entry, token),
291
292
  });
package/src/lib/local.ts CHANGED
@@ -961,6 +961,7 @@ export async function startLocalDaemon(
961
961
  "VELLUM_DEBUG",
962
962
  "VELLUM_DEV",
963
963
  "VELLUM_DESKTOP_APP",
964
+ "VELLUM_DISABLE_PLATFORM",
964
965
  "VELLUM_WORKSPACE_DIR",
965
966
  ]) {
966
967
  if (process.env[key]) {
@@ -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
+ }
package/src/lib/ngrok.ts CHANGED
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
 
13
13
  import { GATEWAY_PORT } from "./constants";
14
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
14
15
 
15
16
  function getDefaultWorkspaceDir(): string {
16
17
  return (
@@ -78,7 +79,7 @@ export function getNgrokVersion(): string | null {
78
79
  */
79
80
  async function queryNgrokTunnels(): Promise<NgrokTunnel[] | null> {
80
81
  try {
81
- const res = await fetch(NGROK_API_URL, {
82
+ const res = await loopbackSafeFetch(NGROK_API_URL, {
82
83
  signal: AbortSignal.timeout(2_000),
83
84
  });
84
85
  if (!res.ok) return null;
@@ -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");
@@ -1,6 +1,7 @@
1
1
  import { getPlatformUrl } from "./platform-client.js";
2
2
  import { DOCKERHUB_IMAGES } from "./docker.js";
3
3
  import type { ServiceName } from "./docker.js";
4
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
4
5
 
5
6
  export interface ResolvedImageRefs {
6
7
  imageTags: Record<ServiceName, string>;
@@ -15,7 +16,7 @@ export interface ResolvedImageRefs {
15
16
  export async function fetchLatestStableVersion(): Promise<string | null> {
16
17
  try {
17
18
  const platformUrl = getPlatformUrl();
18
- const response = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
19
+ const response = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
19
20
  signal: AbortSignal.timeout(10_000),
20
21
  });
21
22
  if (!response.ok) return null;
@@ -80,7 +81,7 @@ async function fetchPlatformImageRefs(
80
81
 
81
82
  log?.(`Fetching releases from ${url}`);
82
83
 
83
- const response = await fetch(url, {
84
+ const response = await loopbackSafeFetch(url, {
84
85
  signal: AbortSignal.timeout(10_000),
85
86
  });
86
87