@vellumai/cli 0.8.10-dev.202606110317.792ac3c → 0.8.10-dev.202606110544.2aed335

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.202606110317.792ac3c",
3
+ "version": "0.8.10-dev.202606110544.2aed335",
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
  });
@@ -16,6 +16,7 @@ import {
16
16
  platformRequestSignedUrl,
17
17
  readPlatformToken,
18
18
  } from "../lib/platform-client.js";
19
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
19
20
 
20
21
  export async function backup(): Promise<void> {
21
22
  const args = process.argv.slice(3);
@@ -112,7 +113,7 @@ export async function backup(): Promise<void> {
112
113
  // Call the export endpoint
113
114
  let response: Response;
114
115
  try {
115
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
116
+ response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/export`, {
116
117
  method: "POST",
117
118
  headers: {
118
119
  Authorization: `Bearer ${accessToken}`,
@@ -138,7 +139,7 @@ export async function backup(): Promise<void> {
138
139
  }
139
140
  if (refreshedToken) {
140
141
  accessToken = refreshedToken;
141
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/export`, {
142
+ response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/export`, {
142
143
  method: "POST",
143
144
  headers: {
144
145
  Authorization: `Bearer ${accessToken}`,
@@ -56,6 +56,7 @@ import {
56
56
  readPlatformToken,
57
57
  } from "../lib/platform-client";
58
58
  import { tuiLog } from "../lib/tui-log";
59
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
59
60
 
60
61
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
61
62
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
@@ -619,7 +620,7 @@ async function handleLocalEndpoints(
619
620
 
620
621
  try {
621
622
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
622
- const proxyRes = await fetch(targetUrl, {
623
+ const proxyRes = await loopbackSafeFetch(targetUrl, {
623
624
  method: req.method,
624
625
  headers,
625
626
  body: hasBody ? req.body : undefined,
@@ -760,7 +761,7 @@ async function runWebInterface(
760
761
  try {
761
762
  const hasBody = req.method !== "GET" && req.method !== "HEAD";
762
763
  const body = hasBody ? await req.arrayBuffer() : undefined;
763
- const proxyRes = await fetch(target.toString(), {
764
+ const proxyRes = await loopbackSafeFetch(target.toString(), {
764
765
  method: req.method,
765
766
  headers,
766
767
  body,
@@ -28,6 +28,7 @@ import {
28
28
  canPromptForConfirmation,
29
29
  confirmAction,
30
30
  } from "../lib/confirm-action.js";
31
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
31
32
 
32
33
  interface DeviceRecord {
33
34
  hashedDeviceId: string;
@@ -108,7 +109,7 @@ async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
108
109
 
109
110
  let response: Response;
110
111
  try {
111
- response = await fetch(`${base}/v1/devices`, {
112
+ response = await loopbackSafeFetch(`${base}/v1/devices`, {
112
113
  method: "GET",
113
114
  headers: getClientRegistrationHeaders(CLI_INTERFACE_ID),
114
115
  });
@@ -186,7 +187,7 @@ async function revokeDevice(
186
187
 
187
188
  let response: Response;
188
189
  try {
189
- response = await fetch(`${base}/v1/devices/revoke`, {
190
+ response = await loopbackSafeFetch(`${base}/v1/devices/revoke`, {
190
191
  method: "POST",
191
192
  headers: {
192
193
  "Content-Type": "application/json",
@@ -26,6 +26,7 @@ import {
26
26
  } from "../lib/client-identity.js";
27
27
  import { GATEWAY_PORT } from "../lib/constants.js";
28
28
  import { getLocalLanIPv4 } from "../lib/local.js";
29
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
29
30
 
30
31
  function isLoopbackHost(url: string): boolean {
31
32
  try {
@@ -154,7 +155,7 @@ export async function pair(): Promise<void> {
154
155
 
155
156
  let response: Response;
156
157
  try {
157
- response = await fetch(`${mintUrl}/v1/pair`, {
158
+ response = await loopbackSafeFetch(`${mintUrl}/v1/pair`, {
158
159
  method: "POST",
159
160
  headers: {
160
161
  "Content-Type": "application/json",
@@ -16,6 +16,7 @@ import {
16
16
  platformPollJobStatus,
17
17
  } from "../lib/platform-client.js";
18
18
  import { performDockerRollback } from "../lib/upgrade-lifecycle.js";
19
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
19
20
 
20
21
  function printUsage(): void {
21
22
  console.log(
@@ -588,7 +589,7 @@ export async function restore(): Promise<void> {
588
589
 
589
590
  let response: Response;
590
591
  try {
591
- response = await fetch(
592
+ response = await loopbackSafeFetch(
592
593
  `${entry.runtimeUrl}/v1/migrations/import-preflight`,
593
594
  {
594
595
  method: "POST",
@@ -694,7 +695,7 @@ export async function restore(): Promise<void> {
694
695
 
695
696
  let response: Response;
696
697
  try {
697
- response = await fetch(`${entry.runtimeUrl}/v1/migrations/import`, {
698
+ response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/import`, {
698
699
  method: "POST",
699
700
  headers: {
700
701
  Authorization: `Bearer ${accessToken}`,
@@ -36,6 +36,7 @@ import {
36
36
  resetLogFile,
37
37
  writeToLogFile,
38
38
  } from "../lib/xdg-log.js";
39
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
39
40
 
40
41
  export { retireLocal };
41
42
 
@@ -96,7 +97,7 @@ async function retireVellum(
96
97
 
97
98
  const platformUrl = runtimeUrl || getPlatformUrl();
98
99
  const url = `${platformUrl}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
99
- const response = await fetch(url, {
100
+ const response = await loopbackSafeFetch(url, {
100
101
  method: "DELETE",
101
102
  headers: await authHeaders(token, runtimeUrl),
102
103
  });
@@ -1,4 +1,5 @@
1
1
  import { readPlatformToken, getWebUrl } from "../lib/platform-client.js";
2
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
2
3
 
3
4
  function printUsage(): void {
4
5
  console.log("Usage: vellum roadmap <subcommand>");
@@ -88,7 +89,7 @@ async function apiFetch(
88
89
  if (options.token) headers["X-Session-Token"] = options.token;
89
90
  if (options.body) headers["Content-Type"] = "application/json";
90
91
 
91
- return fetch(url, {
92
+ return loopbackSafeFetch(url, {
92
93
  method: options.method ?? "GET",
93
94
  headers,
94
95
  body: options.body ? JSON.stringify(options.body) : undefined,
@@ -47,6 +47,7 @@ import {
47
47
  waitForReady,
48
48
  } from "../lib/upgrade-lifecycle.js";
49
49
  import { compareVersions } from "../lib/version-compat.js";
50
+ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
50
51
 
51
52
  interface UpgradeArgs {
52
53
  name: string | null;
@@ -230,7 +231,7 @@ async function upgradeDocker(
230
231
  lastWorkspaceMigrationId?: string;
231
232
  } = {};
232
233
  try {
233
- const healthResp = await fetch(
234
+ const healthResp = await loopbackSafeFetch(
234
235
  `${entry.runtimeUrl}/healthz?include=migrations`,
235
236
  {
236
237
  signal: AbortSignal.timeout(5000),
@@ -695,7 +696,7 @@ async function upgradePlatform(
695
696
  body.version = version;
696
697
  }
697
698
 
698
- const response = await fetch(url, {
699
+ const response = await loopbackSafeFetch(url, {
699
700
  method: "POST",
700
701
  headers,
701
702
  body: JSON.stringify(body),
@@ -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(
@@ -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}`,
package/src/lib/docker.ts CHANGED
@@ -67,6 +67,7 @@ export {
67
67
  ASSISTANT_INTERNAL_PORT,
68
68
  GATEWAY_INTERNAL_PORT,
69
69
  } from "./environments/paths.js";
70
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
70
71
 
71
72
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
72
73
  export const DOCKER_READY_TIMEOUT_MS = 5 * 60 * 1000;
@@ -1530,7 +1531,7 @@ async function waitForGatewayAndLease(opts: {
1530
1531
 
1531
1532
  while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
1532
1533
  try {
1533
- const resp = await fetch(readyUrl, {
1534
+ const resp = await loopbackSafeFetch(readyUrl, {
1534
1535
  signal: AbortSignal.timeout(5000),
1535
1536
  });
1536
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
  });
@@ -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
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { authHeaders, getPlatformUrl } from "./platform-client.js";
10
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Create / Close
@@ -25,7 +26,7 @@ export async function createTerminalSession(
25
26
  if (service) {
26
27
  body.service = service;
27
28
  }
28
- const response = await fetch(
29
+ const response = await loopbackSafeFetch(
29
30
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/`,
30
31
  {
31
32
  method: "POST",
@@ -49,7 +50,7 @@ export async function closeTerminalSession(
49
50
  platformUrl?: string,
50
51
  ): Promise<void> {
51
52
  const baseUrl = platformUrl || getPlatformUrl();
52
- const response = await fetch(
53
+ const response = await loopbackSafeFetch(
53
54
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/`,
54
55
  {
55
56
  method: "DELETE",
@@ -76,7 +77,7 @@ export async function sendTerminalInput(
76
77
  platformUrl?: string,
77
78
  ): Promise<void> {
78
79
  const baseUrl = platformUrl || getPlatformUrl();
79
- const response = await fetch(
80
+ const response = await loopbackSafeFetch(
80
81
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/input/`,
81
82
  {
82
83
  method: "POST",
@@ -100,7 +101,7 @@ export async function resizeTerminalSession(
100
101
  platformUrl?: string,
101
102
  ): Promise<void> {
102
103
  const baseUrl = platformUrl || getPlatformUrl();
103
- const response = await fetch(
104
+ const response = await loopbackSafeFetch(
104
105
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/resize/`,
105
106
  {
106
107
  method: "POST",
@@ -137,7 +138,7 @@ export async function* subscribeTerminalEvents(
137
138
  signal?: AbortSignal,
138
139
  ): AsyncGenerator<TerminalOutputEvent> {
139
140
  const baseUrl = platformUrl || getPlatformUrl();
140
- const response = await fetch(
141
+ const response = await loopbackSafeFetch(
141
142
  `${baseUrl}/v1/assistants/${assistantId}/terminal/sessions/${sessionId}/events/`,
142
143
  {
143
144
  headers: await authHeaders(token, platformUrl),
@@ -27,6 +27,7 @@ import {
27
27
  } from "./statefulset.js";
28
28
  import { exec, execOutput } from "./step-runner.js";
29
29
  import { compareVersions } from "./version-compat.js";
30
+ import { loopbackSafeFetch } from "./loopback-fetch.js";
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Failure log capture
@@ -274,7 +275,7 @@ export async function fetchCurrentVersion(
274
275
  runtimeUrl: string,
275
276
  ): Promise<string | undefined> {
276
277
  try {
277
- const resp = await fetch(`${runtimeUrl}/healthz`, {
278
+ const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
278
279
  signal: AbortSignal.timeout(5000),
279
280
  });
280
281
  if (resp.ok) {
@@ -299,7 +300,7 @@ export async function fetchAssistantIngressUrl(
299
300
  ): Promise<string | undefined> {
300
301
  if (!bearerToken) return undefined;
301
302
  try {
302
- const resp = await fetch(`${runtimeUrl}/integrations/ingress/config`, {
303
+ const resp = await loopbackSafeFetch(`${runtimeUrl}/integrations/ingress/config`, {
303
304
  headers: { Authorization: `Bearer ${bearerToken}` },
304
305
  signal: AbortSignal.timeout(5000),
305
306
  });
@@ -341,7 +342,7 @@ export async function fetchPreviousVersion(
341
342
  try {
342
343
  const { getPlatformUrl } = await import("./platform-client.js");
343
344
  const platformUrl = getPlatformUrl();
344
- const resp = await fetch(`${platformUrl}/v1/releases/?stable=true`, {
345
+ const resp = await loopbackSafeFetch(`${platformUrl}/v1/releases/?stable=true`, {
345
346
  signal: AbortSignal.timeout(10_000),
346
347
  });
347
348
  if (!resp.ok) return undefined;
@@ -373,7 +374,7 @@ export async function waitForReady(runtimeUrl: string): Promise<boolean> {
373
374
 
374
375
  while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
375
376
  try {
376
- const resp = await fetch(readyUrl, {
377
+ const resp = await loopbackSafeFetch(readyUrl, {
377
378
  signal: AbortSignal.timeout(5000),
378
379
  });
379
380
  if (resp.ok) {
@@ -419,7 +420,7 @@ export async function broadcastUpgradeEvent(
419
420
  if (token?.accessToken) {
420
421
  headers["Authorization"] = `Bearer ${token.accessToken}`;
421
422
  }
422
- await fetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
423
+ await loopbackSafeFetch(`${gatewayUrl}/v1/admin/upgrade-broadcast`, {
423
424
  method: "POST",
424
425
  headers,
425
426
  body: JSON.stringify(event),
@@ -448,7 +449,7 @@ export async function commitWorkspaceViaGateway(
448
449
  if (token?.accessToken) {
449
450
  headers["Authorization"] = `Bearer ${token.accessToken}`;
450
451
  }
451
- await fetch(`${gatewayUrl}/v1/admin/workspace-commit`, {
452
+ await loopbackSafeFetch(`${gatewayUrl}/v1/admin/workspace-commit`, {
452
453
  method: "POST",
453
454
  headers,
454
455
  body: JSON.stringify({ message }),
@@ -491,7 +492,7 @@ export async function rollbackMigrations(
491
492
  body.targetWorkspaceMigrationId = targetWorkspaceMigrationId;
492
493
  if (rollbackToRegistryCeiling) body.rollbackToRegistryCeiling = true;
493
494
 
494
- const resp = await fetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
495
+ const resp = await loopbackSafeFetch(`${gatewayUrl}/v1/admin/rollback-migrations`, {
495
496
  method: "POST",
496
497
  headers,
497
498
  body: JSON.stringify(body),
@@ -572,7 +573,7 @@ export async function performDockerRollback(
572
573
  lastWorkspaceMigrationId?: string;
573
574
  } = {};
574
575
  try {
575
- const healthResp = await fetch(
576
+ const healthResp = await loopbackSafeFetch(
576
577
  `${entry.runtimeUrl}/healthz?include=migrations`,
577
578
  { signal: AbortSignal.timeout(5000) },
578
579
  );