@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.
- 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/package.json +1 -1
- package/src/__tests__/guardian-token.test.ts +79 -0
- package/src/__tests__/wake.test.ts +68 -0
- package/src/commands/backup.ts +3 -2
- package/src/commands/client.ts +3 -2
- package/src/commands/devices.ts +3 -2
- package/src/commands/pair.ts +2 -1
- package/src/commands/restore.ts +3 -2
- package/src/commands/retire.ts +2 -1
- package/src/commands/roadmap.ts +2 -1
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +49 -1
- package/src/lib/assistant-client.ts +3 -2
- package/src/lib/backup-ops.ts +5 -4
- package/src/lib/docker.ts +2 -1
- 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/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/terminal-client.ts +6 -5
- package/src/lib/upgrade-lifecycle.ts +9 -8
|
@@ -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 =
|
|
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
|
@@ -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
|
});
|
package/src/commands/backup.ts
CHANGED
|
@@ -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
|
|
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
|
|
142
|
+
response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/export`, {
|
|
142
143
|
method: "POST",
|
|
143
144
|
headers: {
|
|
144
145
|
Authorization: `Bearer ${accessToken}`,
|
package/src/commands/client.ts
CHANGED
|
@@ -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
|
|
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
|
|
764
|
+
const proxyRes = await loopbackSafeFetch(target.toString(), {
|
|
764
765
|
method: req.method,
|
|
765
766
|
headers,
|
|
766
767
|
body,
|
package/src/commands/devices.ts
CHANGED
|
@@ -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
|
|
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
|
|
190
|
+
response = await loopbackSafeFetch(`${base}/v1/devices/revoke`, {
|
|
190
191
|
method: "POST",
|
|
191
192
|
headers: {
|
|
192
193
|
"Content-Type": "application/json",
|
package/src/commands/pair.ts
CHANGED
|
@@ -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
|
|
158
|
+
response = await loopbackSafeFetch(`${mintUrl}/v1/pair`, {
|
|
158
159
|
method: "POST",
|
|
159
160
|
headers: {
|
|
160
161
|
"Content-Type": "application/json",
|
package/src/commands/restore.ts
CHANGED
|
@@ -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
|
|
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
|
|
698
|
+
response = await loopbackSafeFetch(`${entry.runtimeUrl}/v1/migrations/import`, {
|
|
698
699
|
method: "POST",
|
|
699
700
|
headers: {
|
|
700
701
|
Authorization: `Bearer ${accessToken}`,
|
package/src/commands/retire.ts
CHANGED
|
@@ -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
|
|
100
|
+
const response = await loopbackSafeFetch(url, {
|
|
100
101
|
method: "DELETE",
|
|
101
102
|
headers: await authHeaders(token, runtimeUrl),
|
|
102
103
|
});
|
package/src/commands/roadmap.ts
CHANGED
|
@@ -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
|
|
92
|
+
return loopbackSafeFetch(url, {
|
|
92
93
|
method: options.method ?? "GET",
|
|
93
94
|
headers,
|
|
94
95
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
|
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
|
|
699
|
+
const response = await loopbackSafeFetch(url, {
|
|
699
700
|
method: "POST",
|
|
700
701
|
headers,
|
|
701
702
|
body: JSON.stringify(body),
|
package/src/commands/wake.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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}`,
|
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
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
576
|
+
const healthResp = await loopbackSafeFetch(
|
|
576
577
|
`${entry.runtimeUrl}/healthz?include=migrations`,
|
|
577
578
|
{ signal: AbortSignal.timeout(5000) },
|
|
578
579
|
);
|