@vellumai/cli 0.8.10 → 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.
- package/AGENTS.md +2 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +13 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +2 -2
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +20 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +169 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -4
- package/package.json +1 -1
- package/src/__tests__/confirm.test.ts +85 -0
- package/src/__tests__/device-id.test.ts +167 -0
- package/src/__tests__/guardian-token.test.ts +79 -0
- package/src/__tests__/helpers/env.ts +19 -0
- package/src/__tests__/statefulset.test.ts +149 -0
- package/src/__tests__/upgrade-replay-env.test.ts +165 -0
- package/src/__tests__/wake.test.ts +68 -0
- package/src/commands/backup.ts +3 -2
- package/src/commands/client.ts +22 -5
- package/src/commands/confirm.ts +144 -0
- package/src/commands/connect.ts +1 -1
- package/src/commands/devices.ts +4 -3
- package/src/commands/hatch.ts +16 -1
- package/src/commands/pair.ts +3 -2
- package/src/commands/restore.ts +3 -2
- package/src/commands/retire.ts +2 -1
- package/src/commands/roadmap.ts +2 -1
- package/src/commands/rollback.ts +9 -37
- package/src/commands/unpair.ts +1 -1
- package/src/commands/upgrade.ts +13 -44
- package/src/commands/wake.ts +49 -1
- package/src/index.ts +11 -4
- package/src/lib/assistant-client.ts +3 -2
- package/src/lib/backup-ops.ts +5 -4
- package/src/lib/device-id.ts +85 -0
- package/src/lib/docker.ts +19 -3
- package/src/lib/guardian-token.ts +44 -8
- package/src/lib/hatch-local.ts +2 -1
- package/src/lib/health-check.ts +6 -4
- package/src/lib/http-client.ts +3 -1
- package/src/lib/local-runtime-client.ts +5 -4
- package/src/lib/local.ts +1 -0
- package/src/lib/loopback-fetch.ts +28 -0
- package/src/lib/ngrok.ts +2 -1
- package/src/lib/platform-client.ts +28 -21
- package/src/lib/platform-releases.ts +3 -2
- package/src/lib/statefulset.ts +43 -0
- package/src/lib/terminal-client.ts +6 -5
- 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
|
|
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
|
|
217
|
+
return loopbackSafeFetch(url, {
|
|
217
218
|
method,
|
|
218
219
|
headers,
|
|
219
220
|
body: jsonBody,
|
package/src/lib/backup-ops.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1327
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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.
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -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
|
|
362
|
+
const res = await loopbackSafeFetch(healthUrl, {
|
|
362
363
|
signal: AbortSignal.timeout(3000),
|
|
363
364
|
});
|
|
364
365
|
if (res.ok) {
|
package/src/lib/health-check.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
193
|
+
const response = await loopbackSafeFetch(url, {
|
|
192
194
|
signal: controller.signal,
|
|
193
195
|
headers,
|
|
194
196
|
});
|
package/src/lib/http-client.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
289
|
+
loopbackSafeFetch(url, {
|
|
289
290
|
method: "GET",
|
|
290
291
|
headers: await migrationRequestHeaders(entry, token),
|
|
291
292
|
});
|
package/src/lib/local.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1044
|
-
|
|
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
|
|
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
|
|
84
|
+
const response = await loopbackSafeFetch(url, {
|
|
84
85
|
signal: AbortSignal.timeout(10_000),
|
|
85
86
|
});
|
|
86
87
|
|