@vellumai/cli 0.7.1 → 0.7.2
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 +3 -11
- package/bun.lock +0 -15
- package/package.json +1 -6
- package/src/__tests__/backup.test.ts +121 -5
- package/src/__tests__/teleport.test.ts +512 -10
- package/src/commands/backup.ts +35 -2
- package/src/commands/client.ts +47 -6
- package/src/commands/events.ts +3 -0
- package/src/commands/exec.ts +13 -4
- package/src/commands/hatch.ts +1 -1
- package/src/commands/restore.ts +7 -1
- package/src/commands/setup.ts +38 -73
- package/src/commands/teleport.ts +111 -11
- package/src/commands/upgrade.ts +6 -0
- package/src/commands/wake.ts +5 -16
- package/src/components/DefaultMainScreen.tsx +40 -126
- package/src/index.ts +1 -7
- package/src/lib/__tests__/docker.test.ts +50 -32
- package/src/lib/__tests__/local-runtime-client.test.ts +186 -0
- package/src/lib/__tests__/platform-client-signed-url.test.ts +235 -0
- package/src/lib/__tests__/runtime-url.test.ts +39 -1
- package/src/lib/assistant-client.ts +13 -5
- package/src/lib/assistant-config.ts +0 -25
- package/src/lib/backup-ops.ts +43 -17
- package/src/lib/docker-statefulset.ts +381 -0
- package/src/lib/docker.ts +8 -247
- package/src/lib/guardian-token.ts +56 -6
- package/src/lib/hatch-local.ts +3 -26
- package/src/lib/local-runtime-client.ts +82 -1
- package/src/lib/local.ts +9 -7
- package/src/lib/ngrok.ts +36 -26
- package/src/lib/platform-client.ts +96 -1
- package/src/lib/retire-local.ts +2 -2
- package/src/lib/runtime-url.ts +22 -0
- package/src/lib/upgrade-lifecycle.ts +65 -0
- package/src/commands/pair.ts +0 -212
|
@@ -20,9 +20,11 @@ const DEVICE_ID_SALT = "vellum-assistant-host-id";
|
|
|
20
20
|
export interface GuardianTokenData {
|
|
21
21
|
guardianPrincipalId: string;
|
|
22
22
|
accessToken: string;
|
|
23
|
-
|
|
23
|
+
/** ISO date string or epoch-ms number as returned by the gateway. */
|
|
24
|
+
accessTokenExpiresAt: string | number;
|
|
24
25
|
refreshToken: string;
|
|
25
|
-
|
|
26
|
+
/** ISO date string or epoch-ms number as returned by the gateway. */
|
|
27
|
+
refreshTokenExpiresAt: string | number;
|
|
26
28
|
refreshAfter: string;
|
|
27
29
|
isNew: boolean;
|
|
28
30
|
deviceId: string;
|
|
@@ -104,7 +106,7 @@ export function getOrCreatePersistedDeviceId(): string {
|
|
|
104
106
|
/**
|
|
105
107
|
* Compute a stable device identifier matching the native client conventions.
|
|
106
108
|
*
|
|
107
|
-
* - macOS: SHA-256 of IOPlatformUUID + salt
|
|
109
|
+
* - macOS: SHA-256 of IOPlatformUUID + salt
|
|
108
110
|
* - Linux: SHA-256 of /etc/machine-id + salt
|
|
109
111
|
* - Windows: SHA-256 of HKLM MachineGuid + salt
|
|
110
112
|
* - Fallback: persisted random UUID in XDG config
|
|
@@ -158,6 +160,54 @@ export function saveGuardianToken(
|
|
|
158
160
|
chmodSync(tokenPath, 0o600);
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Call POST /v1/guardian/refresh on the remote gateway to obtain a new
|
|
165
|
+
* access token using an existing (possibly expired) access token for auth.
|
|
166
|
+
* Returns the refreshed token data (persisted locally), or null if the
|
|
167
|
+
* refresh fails (e.g. no stored token, or refresh token itself is expired).
|
|
168
|
+
*/
|
|
169
|
+
export async function refreshGuardianToken(
|
|
170
|
+
gatewayUrl: string,
|
|
171
|
+
assistantId: string,
|
|
172
|
+
): Promise<GuardianTokenData | null> {
|
|
173
|
+
const tokenData = loadGuardianToken(assistantId);
|
|
174
|
+
if (!tokenData) return null;
|
|
175
|
+
|
|
176
|
+
// Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
|
|
177
|
+
// returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
|
|
178
|
+
const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
|
|
179
|
+
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now()) return null;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: {
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
Authorization: `Bearer ${tokenData.accessToken}`,
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify({ refreshToken: tokenData.refreshToken }),
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) return null;
|
|
191
|
+
|
|
192
|
+
const json = (await response.json()) as Record<string, unknown>;
|
|
193
|
+
const refreshed: GuardianTokenData = {
|
|
194
|
+
guardianPrincipalId: (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
|
|
195
|
+
accessToken: json.accessToken as string,
|
|
196
|
+
accessTokenExpiresAt: (json.accessTokenExpiresAt as string | number) ?? tokenData.accessTokenExpiresAt,
|
|
197
|
+
refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
|
|
198
|
+
refreshTokenExpiresAt: (json.refreshTokenExpiresAt as string | number) ?? tokenData.refreshTokenExpiresAt,
|
|
199
|
+
refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
|
|
200
|
+
isNew: false,
|
|
201
|
+
deviceId: tokenData.deviceId,
|
|
202
|
+
leasedAt: new Date().toISOString(),
|
|
203
|
+
};
|
|
204
|
+
saveGuardianToken(assistantId, refreshed);
|
|
205
|
+
return refreshed;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
/**
|
|
162
212
|
* Call POST /v1/guardian/init on the remote gateway to bootstrap a JWT
|
|
163
213
|
* credential pair. The returned tokens are persisted locally under
|
|
@@ -190,9 +240,9 @@ export async function leaseGuardianToken(
|
|
|
190
240
|
const tokenData: GuardianTokenData = {
|
|
191
241
|
guardianPrincipalId: json.guardianPrincipalId as string,
|
|
192
242
|
accessToken: json.accessToken as string,
|
|
193
|
-
accessTokenExpiresAt: json.accessTokenExpiresAt as string,
|
|
243
|
+
accessTokenExpiresAt: json.accessTokenExpiresAt as string | number,
|
|
194
244
|
refreshToken: json.refreshToken as string,
|
|
195
|
-
refreshTokenExpiresAt: json.refreshTokenExpiresAt as string,
|
|
245
|
+
refreshTokenExpiresAt: json.refreshTokenExpiresAt as string | number,
|
|
196
246
|
refreshAfter: json.refreshAfter as string,
|
|
197
247
|
isNew: json.isNew as boolean,
|
|
198
248
|
deviceId,
|
|
@@ -248,7 +298,7 @@ export function seedGuardianTokenFromSiblingEnv(assistantId: string): boolean {
|
|
|
248
298
|
try {
|
|
249
299
|
const raw = readFileSync(sibling);
|
|
250
300
|
const parsed = JSON.parse(raw.toString("utf-8")) as GuardianTokenData;
|
|
251
|
-
const refreshExpiry = Date
|
|
301
|
+
const refreshExpiry = new Date(parsed.refreshTokenExpiresAt).getTime();
|
|
252
302
|
if (!Number.isFinite(refreshExpiry) || refreshExpiry <= now) continue;
|
|
253
303
|
const dir = dirname(destPath);
|
|
254
304
|
if (!existsSync(dir)) {
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
readlinkSync,
|
|
6
6
|
symlinkSync,
|
|
7
7
|
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
8
|
appendFileSync,
|
|
10
9
|
readFileSync,
|
|
11
10
|
} from "fs";
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
findAssistantByName,
|
|
21
20
|
saveAssistantEntry,
|
|
22
21
|
setActiveAssistant,
|
|
23
|
-
syncConfigToLockfile,
|
|
24
22
|
} from "./assistant-config.js";
|
|
25
23
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
26
24
|
import type { Species } from "./constants.js";
|
|
@@ -31,7 +29,6 @@ import {
|
|
|
31
29
|
startGateway,
|
|
32
30
|
stopLocalProcesses,
|
|
33
31
|
} from "./local.js";
|
|
34
|
-
import { maybeStartNgrokTunnel } from "./ngrok.js";
|
|
35
32
|
|
|
36
33
|
import { generateInstanceName } from "./random-name.js";
|
|
37
34
|
import { leaseGuardianToken } from "./guardian-token.js";
|
|
@@ -223,9 +220,6 @@ export async function hatchLocal(
|
|
|
223
220
|
}
|
|
224
221
|
|
|
225
222
|
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
226
|
-
// Set BASE_DATA_DIR so ngrok reads the correct instance config. Keep the
|
|
227
|
-
// lockfile save/sync inside the same scope so syncConfigToLockfile() reads
|
|
228
|
-
// this instance's workspace/config.json rather than a stale default path.
|
|
229
223
|
const localEntry: AssistantEntry = {
|
|
230
224
|
assistantId: instanceName,
|
|
231
225
|
runtimeUrl,
|
|
@@ -236,26 +230,9 @@ export async function hatchLocal(
|
|
|
236
230
|
resources: { ...resources, signingKey },
|
|
237
231
|
};
|
|
238
232
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
243
|
-
if (ngrokChild?.pid) {
|
|
244
|
-
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
245
|
-
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
emitProgress(6, 6, "Saving configuration...");
|
|
249
|
-
saveAssistantEntry(localEntry);
|
|
250
|
-
setActiveAssistant(instanceName);
|
|
251
|
-
syncConfigToLockfile();
|
|
252
|
-
} finally {
|
|
253
|
-
if (prevBaseDataDir !== undefined) {
|
|
254
|
-
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
255
|
-
} else {
|
|
256
|
-
delete process.env.BASE_DATA_DIR;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
233
|
+
emitProgress(6, 6, "Saving configuration...");
|
|
234
|
+
saveAssistantEntry(localEntry);
|
|
235
|
+
setActiveAssistant(instanceName);
|
|
259
236
|
|
|
260
237
|
if (process.env.VELLUM_DESKTOP_APP) {
|
|
261
238
|
installCLISymlink();
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
2
2
|
import {
|
|
3
3
|
authHeaders,
|
|
4
|
+
invalidateOrgIdCache,
|
|
4
5
|
parseUnifiedJobStatus,
|
|
5
6
|
type UnifiedJobStatus,
|
|
6
7
|
} from "./platform-client.js";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
resolveRuntimeMigrationUrl,
|
|
10
|
+
resolveRuntimeUrl,
|
|
11
|
+
} from "./runtime-url.js";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Thrown when the local runtime returns 409 for an export/import request
|
|
@@ -229,3 +233,80 @@ export async function localRuntimePollJobStatus(
|
|
|
229
233
|
>[0];
|
|
230
234
|
return parseUnifiedJobStatus(raw);
|
|
231
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The subset of `/v1/health` we care about. The runtime's full response
|
|
239
|
+
* includes additional fields (status, disk, memory, cpu, migrations, etc.)
|
|
240
|
+
* — we only model `version` here because that's all the CLI consumes today.
|
|
241
|
+
*/
|
|
242
|
+
export interface RuntimeIdentity {
|
|
243
|
+
version: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fetch the target runtime's APP_VERSION via `/v1/health`. Used by
|
|
248
|
+
* `vellum teleport` and `vellum backup` to stamp the exported bundle's
|
|
249
|
+
* `min_runtime_version` with the version of the runtime that actually
|
|
250
|
+
* produced it — which can diverge from the orchestrating CLI's version when
|
|
251
|
+
* the target was upgraded independently.
|
|
252
|
+
*
|
|
253
|
+
* GETs `/v1/health` (not `/v1/identity`) so the call works on freshly-
|
|
254
|
+
* hatched runtimes that haven't completed onboarding. The `/v1/identity`
|
|
255
|
+
* handler reads `IDENTITY.md` from the workspace and 404s if it's missing
|
|
256
|
+
* — and `IDENTITY.md` is only written during onboarding, not hatch. The
|
|
257
|
+
* `/v1/health` handler returns the same `version` field unconditionally
|
|
258
|
+
* (no filesystem reads), so it's safe to call against any running runtime.
|
|
259
|
+
*
|
|
260
|
+
* For local/docker assistants this GETs `{runtimeUrl}/v1/health` with
|
|
261
|
+
* guardian-token bearer auth. For platform-managed (cloud="vellum")
|
|
262
|
+
* assistants the URL is rewritten to the wildcard runtime proxy shape
|
|
263
|
+
* `{platformUrl}/v1/assistants/<assistantId>/health` and authenticated via
|
|
264
|
+
* the platform token.
|
|
265
|
+
*
|
|
266
|
+
* For the vellum target this is the FIRST network call in the
|
|
267
|
+
* teleport/backup export flow, so a stale `Vellum-Organization-Id` cache
|
|
268
|
+
* entry would surface as a hard abort before any retry-friendly call (like
|
|
269
|
+
* `platformRequestSignedUrl`) gets a chance to recover. Mirror that helper's
|
|
270
|
+
* one-shot 401-retry: invalidate the org-ID cache and retry once. Local /
|
|
271
|
+
* docker entries do not use the org-ID cache and are wrapped in
|
|
272
|
+
* `callRuntimeWithAuthRetry` by callers for guardian-token refresh, so the
|
|
273
|
+
* retry is intentionally vellum-only.
|
|
274
|
+
*
|
|
275
|
+
* The function name is intentionally retained ("identity-ish info about the
|
|
276
|
+
* runtime") even though the implementation now hits `/v1/health` — renaming
|
|
277
|
+
* would force changes in 4+ callsites for no behavioral benefit.
|
|
278
|
+
*
|
|
279
|
+
* Throws on non-2xx so callers can surface the failure (we never silently
|
|
280
|
+
* fall back — see teleport.ts call site).
|
|
281
|
+
*/
|
|
282
|
+
export async function localRuntimeIdentity(
|
|
283
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
284
|
+
token: string,
|
|
285
|
+
): Promise<RuntimeIdentity> {
|
|
286
|
+
const url = resolveRuntimeUrl(entry, "health");
|
|
287
|
+
const doRequest = async (): Promise<Response> =>
|
|
288
|
+
fetch(url, {
|
|
289
|
+
method: "GET",
|
|
290
|
+
headers: await migrationRequestHeaders(entry, token),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
let response = await doRequest();
|
|
294
|
+
if (response.status === 401 && entry.cloud === "vellum") {
|
|
295
|
+
// `entry.runtimeUrl` is the platform host for vellum-cloud entries
|
|
296
|
+
// (the wildcard runtime proxy lives there). Pass it as the cache key
|
|
297
|
+
// platformUrl so we invalidate the same entry that authHeaders cached.
|
|
298
|
+
invalidateOrgIdCache(token, entry.runtimeUrl);
|
|
299
|
+
response = await doRequest();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Failed to fetch runtime identity: ${response.status} ${response.statusText}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const body = (await response.json()) as { version?: unknown };
|
|
308
|
+
if (typeof body.version !== "string" || !body.version) {
|
|
309
|
+
throw new Error("Runtime identity response missing version");
|
|
310
|
+
}
|
|
311
|
+
return { version: body.version };
|
|
312
|
+
}
|
package/src/lib/local.ts
CHANGED
|
@@ -111,7 +111,9 @@ function computeIpcSocketDirOverride(workspaceDir: string): string | undefined {
|
|
|
111
111
|
* a short override directory and set all IPC socket env vars on the target
|
|
112
112
|
* env object. No-op on non-macOS or when paths are within limits.
|
|
113
113
|
*/
|
|
114
|
-
function applyIpcSocketDirOverride(
|
|
114
|
+
function applyIpcSocketDirOverride(
|
|
115
|
+
env: Record<string, string | undefined>,
|
|
116
|
+
): void {
|
|
115
117
|
const workspaceDir =
|
|
116
118
|
env.VELLUM_WORKSPACE_DIR || join(homedir(), ".vellum", "workspace");
|
|
117
119
|
const override = computeIpcSocketDirOverride(workspaceDir);
|
|
@@ -417,6 +419,8 @@ async function startDaemonFromSource(
|
|
|
417
419
|
options.defaultWorkspaceConfigPath;
|
|
418
420
|
}
|
|
419
421
|
|
|
422
|
+
applyIpcSocketDirOverride(env);
|
|
423
|
+
|
|
420
424
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
421
425
|
// detect the in-progress spawn and wait instead of racing.
|
|
422
426
|
writeFileSync(pidFile, "starting", "utf-8");
|
|
@@ -552,6 +556,8 @@ async function startDaemonWatchFromSource(
|
|
|
552
556
|
options.defaultWorkspaceConfigPath;
|
|
553
557
|
}
|
|
554
558
|
|
|
559
|
+
applyIpcSocketDirOverride(env);
|
|
560
|
+
|
|
555
561
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
556
562
|
// detect the in-progress spawn and wait instead of racing.
|
|
557
563
|
writeFileSync(pidFile, "starting", "utf-8");
|
|
@@ -1183,10 +1189,6 @@ export async function startGateway(
|
|
|
1183
1189
|
|
|
1184
1190
|
applyIpcSocketDirOverride(gatewayEnv);
|
|
1185
1191
|
|
|
1186
|
-
if (publicUrl) {
|
|
1187
|
-
console.log(` HTTP URL: ${publicUrl}`);
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
1192
|
let gateway;
|
|
1191
1193
|
|
|
1192
1194
|
const gatewayBinary = join(dirname(process.execPath), "vellum-gateway");
|
|
@@ -1232,8 +1234,8 @@ export async function startGateway(
|
|
|
1232
1234
|
const gatewayUrl = publicUrl || `http://localhost:${effectiveGatewayPort}`;
|
|
1233
1235
|
|
|
1234
1236
|
// Wait for the gateway to be responsive before returning. Without this,
|
|
1235
|
-
// callers
|
|
1236
|
-
//
|
|
1237
|
+
// callers may try to connect before the HTTP server is listening and get
|
|
1238
|
+
// connection-refused errors.
|
|
1237
1239
|
const start = Date.now();
|
|
1238
1240
|
const timeoutMs = 30000;
|
|
1239
1241
|
let ready = false;
|
package/src/lib/ngrok.ts
CHANGED
|
@@ -12,13 +12,19 @@ import { dirname, join } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { GATEWAY_PORT } from "./constants";
|
|
14
14
|
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
function getDefaultWorkspaceDir(): string {
|
|
16
|
+
return (
|
|
17
|
+
process.env.VELLUM_WORKSPACE_DIR?.trim() ||
|
|
18
|
+
join(homedir(), ".vellum", "workspace")
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getConfigPath(workspaceDir: string): string {
|
|
23
|
+
return join(workspaceDir, "config.json");
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
function loadRawConfig(): Record<string, unknown> {
|
|
21
|
-
const configPath = getConfigPath();
|
|
26
|
+
function loadRawConfig(workspaceDir: string): Record<string, unknown> {
|
|
27
|
+
const configPath = getConfigPath(workspaceDir);
|
|
22
28
|
if (!existsSync(configPath)) return {};
|
|
23
29
|
return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
|
|
24
30
|
string,
|
|
@@ -26,8 +32,11 @@ function loadRawConfig(): Record<string, unknown> {
|
|
|
26
32
|
>;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
function saveRawConfig(
|
|
30
|
-
|
|
35
|
+
function saveRawConfig(
|
|
36
|
+
workspaceDir: string,
|
|
37
|
+
config: Record<string, unknown>,
|
|
38
|
+
): void {
|
|
39
|
+
const configPath = getConfigPath(workspaceDir);
|
|
31
40
|
const dir = dirname(configPath);
|
|
32
41
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
33
42
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
@@ -182,33 +191,33 @@ export async function waitForNgrokUrl(
|
|
|
182
191
|
/**
|
|
183
192
|
* Persist a public ingress URL to the workspace config and enable ingress.
|
|
184
193
|
*/
|
|
185
|
-
function saveIngressUrl(publicUrl: string): void {
|
|
186
|
-
const config = loadRawConfig();
|
|
194
|
+
function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
|
|
195
|
+
const config = loadRawConfig(workspaceDir);
|
|
187
196
|
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
188
197
|
ingress.publicBaseUrl = publicUrl;
|
|
189
198
|
ingress.enabled = true;
|
|
190
199
|
config.ingress = ingress;
|
|
191
|
-
saveRawConfig(config);
|
|
200
|
+
saveRawConfig(workspaceDir, config);
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
/**
|
|
195
204
|
* Clear the ingress public base URL from the workspace config.
|
|
196
205
|
*/
|
|
197
|
-
function clearIngressUrl(): void {
|
|
198
|
-
const config = loadRawConfig();
|
|
206
|
+
function clearIngressUrl(workspaceDir: string): void {
|
|
207
|
+
const config = loadRawConfig(workspaceDir);
|
|
199
208
|
const ingress = (config.ingress ?? {}) as Record<string, unknown>;
|
|
200
209
|
delete ingress.publicBaseUrl;
|
|
201
210
|
config.ingress = ingress;
|
|
202
|
-
saveRawConfig(config);
|
|
211
|
+
saveRawConfig(workspaceDir, config);
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
/**
|
|
206
215
|
* Check whether any webhook-based integrations (e.g. Telegram, Twilio) are
|
|
207
216
|
* configured that require a public ingress URL.
|
|
208
217
|
*/
|
|
209
|
-
function hasWebhookIntegrationsConfigured(): boolean {
|
|
218
|
+
function hasWebhookIntegrationsConfigured(workspaceDir: string): boolean {
|
|
210
219
|
try {
|
|
211
|
-
const config = loadRawConfig();
|
|
220
|
+
const config = loadRawConfig(workspaceDir);
|
|
212
221
|
const telegram = config.telegram as Record<string, unknown> | undefined;
|
|
213
222
|
if (telegram?.botUsername) return true;
|
|
214
223
|
const twilio = config.twilio as Record<string, unknown> | undefined;
|
|
@@ -223,9 +232,9 @@ function hasWebhookIntegrationsConfigured(): boolean {
|
|
|
223
232
|
* Check whether a non-ngrok ingress URL is already configured (e.g. custom
|
|
224
233
|
* domain or cloud deployment), meaning ngrok is not needed.
|
|
225
234
|
*/
|
|
226
|
-
function hasNonNgrokIngressUrl(): boolean {
|
|
235
|
+
function hasNonNgrokIngressUrl(workspaceDir: string): boolean {
|
|
227
236
|
try {
|
|
228
|
-
const config = loadRawConfig();
|
|
237
|
+
const config = loadRawConfig(workspaceDir);
|
|
229
238
|
const ingress = config.ingress as Record<string, unknown> | undefined;
|
|
230
239
|
const publicBaseUrl = ingress?.publicBaseUrl;
|
|
231
240
|
if (!publicBaseUrl || typeof publicBaseUrl !== "string") return false;
|
|
@@ -244,6 +253,7 @@ function hasNonNgrokIngressUrl(): boolean {
|
|
|
244
253
|
*/
|
|
245
254
|
export async function maybeStartNgrokTunnel(
|
|
246
255
|
targetPort: number,
|
|
256
|
+
workspaceDir: string,
|
|
247
257
|
): Promise<ChildProcess | null> {
|
|
248
258
|
// Managed/containerized deployments route webhooks through the platform's
|
|
249
259
|
// callback proxy. ngrok is not needed and would not be reachable from the
|
|
@@ -252,8 +262,8 @@ export async function maybeStartNgrokTunnel(
|
|
|
252
262
|
process.env.IS_CONTAINERIZED === "true" ||
|
|
253
263
|
process.env.IS_CONTAINERIZED === "1";
|
|
254
264
|
if (isContainerized) return null;
|
|
255
|
-
if (!hasWebhookIntegrationsConfigured()) return null;
|
|
256
|
-
if (hasNonNgrokIngressUrl()) return null;
|
|
265
|
+
if (!hasWebhookIntegrationsConfigured(workspaceDir)) return null;
|
|
266
|
+
if (hasNonNgrokIngressUrl(workspaceDir)) return null;
|
|
257
267
|
|
|
258
268
|
const version = getNgrokVersion();
|
|
259
269
|
if (!version) return null;
|
|
@@ -262,7 +272,7 @@ export async function maybeStartNgrokTunnel(
|
|
|
262
272
|
const existingUrl = await findExistingTunnel(targetPort);
|
|
263
273
|
if (existingUrl) {
|
|
264
274
|
console.log(` Found existing ngrok tunnel: ${existingUrl}`);
|
|
265
|
-
saveIngressUrl(existingUrl);
|
|
275
|
+
saveIngressUrl(workspaceDir, existingUrl);
|
|
266
276
|
return null;
|
|
267
277
|
}
|
|
268
278
|
|
|
@@ -274,14 +284,13 @@ export async function maybeStartNgrokTunnel(
|
|
|
274
284
|
// 2. If pipe handles are destroyed, SIGPIPE kills ngrok on its next write.
|
|
275
285
|
// Writing to a log file sidesteps both issues — the file descriptor is
|
|
276
286
|
// inherited by the detached ngrok process and remains valid after CLI exit.
|
|
277
|
-
const
|
|
278
|
-
const ngrokLogPath = join(root, "workspace", "data", "logs", "ngrok.log");
|
|
287
|
+
const ngrokLogPath = join(workspaceDir, "data", "logs", "ngrok.log");
|
|
279
288
|
const ngrokProcess = startNgrokProcess(targetPort, ngrokLogPath);
|
|
280
289
|
ngrokProcess.unref();
|
|
281
290
|
|
|
282
291
|
try {
|
|
283
292
|
const publicUrl = await waitForNgrokUrl();
|
|
284
|
-
saveIngressUrl(publicUrl);
|
|
293
|
+
saveIngressUrl(workspaceDir, publicUrl);
|
|
285
294
|
console.log(` Tunnel established: ${publicUrl}`);
|
|
286
295
|
|
|
287
296
|
return ngrokProcess;
|
|
@@ -317,12 +326,13 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
317
326
|
console.log(`Using ${version}`);
|
|
318
327
|
|
|
319
328
|
const port = GATEWAY_PORT;
|
|
329
|
+
const workspaceDir = getDefaultWorkspaceDir();
|
|
320
330
|
|
|
321
331
|
// Check for an existing ngrok tunnel pointing at the gateway
|
|
322
332
|
const existingUrl = await findExistingTunnel(port);
|
|
323
333
|
if (existingUrl) {
|
|
324
334
|
console.log(`Found existing ngrok tunnel: ${existingUrl}`);
|
|
325
|
-
saveIngressUrl(existingUrl);
|
|
335
|
+
saveIngressUrl(workspaceDir, existingUrl);
|
|
326
336
|
console.log("Ingress URL saved to config.");
|
|
327
337
|
console.log("");
|
|
328
338
|
console.log(
|
|
@@ -349,7 +359,7 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
349
359
|
}
|
|
350
360
|
if (publicUrl) {
|
|
351
361
|
console.log("\nClearing ingress URL from config...");
|
|
352
|
-
clearIngressUrl();
|
|
362
|
+
clearIngressUrl(workspaceDir);
|
|
353
363
|
}
|
|
354
364
|
};
|
|
355
365
|
|
|
@@ -398,7 +408,7 @@ export async function runNgrokTunnel(): Promise<void> {
|
|
|
398
408
|
console.log(`Tunnel established: ${publicUrl}`);
|
|
399
409
|
console.log(`Forwarding to: localhost:${port}`);
|
|
400
410
|
|
|
401
|
-
saveIngressUrl(publicUrl);
|
|
411
|
+
saveIngressUrl(workspaceDir, publicUrl);
|
|
402
412
|
console.log("Ingress URL saved to config.");
|
|
403
413
|
console.log("");
|
|
404
414
|
console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
|
|
@@ -99,6 +99,23 @@ function tokenAuthHeader(token: string): Record<string, string> {
|
|
|
99
99
|
const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
|
|
100
100
|
const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Drop the cached org ID for a given (token, platformUrl) pair. Used by the
|
|
104
|
+
* one-shot 401-retry path: a 401 on a session-token request frequently means
|
|
105
|
+
* the cached `Vellum-Organization-Id` header is stale (e.g. user switched
|
|
106
|
+
* orgs in another tab). Clearing the entry forces the next `authHeaders`
|
|
107
|
+
* call to refetch the org ID from the platform.
|
|
108
|
+
*
|
|
109
|
+
* Exported so other modules (e.g. local-runtime-client) can implement the
|
|
110
|
+
* same retry pattern without needing direct access to the cache map.
|
|
111
|
+
*/
|
|
112
|
+
export function invalidateOrgIdCache(
|
|
113
|
+
token: string,
|
|
114
|
+
platformUrl?: string,
|
|
115
|
+
): void {
|
|
116
|
+
orgIdCache.delete(`${token}::${platformUrl ?? ""}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
/**
|
|
103
120
|
* Returns the full set of headers needed for an authenticated platform
|
|
104
121
|
* API request:
|
|
@@ -468,6 +485,7 @@ export async function hatchAssistant(
|
|
|
468
485
|
method: "POST",
|
|
469
486
|
headers: await authHeaders(token, platformUrl),
|
|
470
487
|
body: JSON.stringify({}),
|
|
488
|
+
signal: AbortSignal.timeout(300_000),
|
|
471
489
|
});
|
|
472
490
|
|
|
473
491
|
if (response.ok) {
|
|
@@ -805,6 +823,45 @@ export function parseUnifiedJobStatus(
|
|
|
805
823
|
};
|
|
806
824
|
}
|
|
807
825
|
|
|
826
|
+
export interface BundleCompatibility {
|
|
827
|
+
min_runtime_version: string;
|
|
828
|
+
max_runtime_version: string | null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Thrown by platformRequestSignedUrl when the platform rejects a download
|
|
833
|
+
* signed-URL request because the target runtime version is outside the
|
|
834
|
+
* ExportJob's [min_runtime_version, max_runtime_version] band. Terminal
|
|
835
|
+
* — callers must NOT retry; surface to the user and abort the
|
|
836
|
+
* teleport/restore wizard.
|
|
837
|
+
*/
|
|
838
|
+
export class VersionMismatchError extends Error {
|
|
839
|
+
readonly bundleCompat: BundleCompatibility;
|
|
840
|
+
readonly targetRuntimeVersion: string;
|
|
841
|
+
|
|
842
|
+
constructor(bundleCompat: BundleCompatibility, targetRuntimeVersion: string) {
|
|
843
|
+
super(
|
|
844
|
+
VersionMismatchError.formatMessage(bundleCompat, targetRuntimeVersion),
|
|
845
|
+
);
|
|
846
|
+
this.name = "VersionMismatchError";
|
|
847
|
+
this.bundleCompat = bundleCompat;
|
|
848
|
+
this.targetRuntimeVersion = targetRuntimeVersion;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
static formatMessage(
|
|
852
|
+
compat: BundleCompatibility,
|
|
853
|
+
targetRuntimeVersion: string,
|
|
854
|
+
): string {
|
|
855
|
+
const range = compat.max_runtime_version
|
|
856
|
+
? `${compat.min_runtime_version}–${compat.max_runtime_version}`
|
|
857
|
+
: `${compat.min_runtime_version}+`;
|
|
858
|
+
return (
|
|
859
|
+
`Cannot import: bundle requires runtime ${range}, but this runtime is ${targetRuntimeVersion}. ` +
|
|
860
|
+
`Update your runtime before importing.`
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
808
865
|
/**
|
|
809
866
|
* Request a signed URL from the platform for either uploading a new bundle
|
|
810
867
|
* or downloading an existing one. Calls `POST /v1/migrations/signed-url/`.
|
|
@@ -816,6 +873,9 @@ export function parseUnifiedJobStatus(
|
|
|
816
873
|
*
|
|
817
874
|
* Retries once with a fresh org-ID cache on 401 to match the retry pattern
|
|
818
875
|
* used by other authenticated platform helpers.
|
|
876
|
+
*
|
|
877
|
+
* Throws {@link VersionMismatchError} on a 422 `version_mismatch` response,
|
|
878
|
+
* which is terminal — callers must NOT retry.
|
|
819
879
|
*/
|
|
820
880
|
export async function platformRequestSignedUrl(
|
|
821
881
|
params: {
|
|
@@ -823,6 +883,11 @@ export async function platformRequestSignedUrl(
|
|
|
823
883
|
bundleKey?: string;
|
|
824
884
|
contentType?: string;
|
|
825
885
|
contentLength?: number;
|
|
886
|
+
// Source-side, upload only: runtime version that produced the bundle.
|
|
887
|
+
minRuntimeVersion?: string;
|
|
888
|
+
maxRuntimeVersion?: string | null;
|
|
889
|
+
// Target-side, download only: runtime version that will import.
|
|
890
|
+
targetRuntimeVersion?: string;
|
|
826
891
|
},
|
|
827
892
|
token: string,
|
|
828
893
|
platformUrl?: string,
|
|
@@ -839,6 +904,17 @@ export async function platformRequestSignedUrl(
|
|
|
839
904
|
if (params.contentLength !== undefined) {
|
|
840
905
|
body.content_length = params.contentLength;
|
|
841
906
|
}
|
|
907
|
+
if (params.minRuntimeVersion !== undefined) {
|
|
908
|
+
body.min_runtime_version = params.minRuntimeVersion;
|
|
909
|
+
}
|
|
910
|
+
if (params.maxRuntimeVersion !== undefined) {
|
|
911
|
+
// Explicit null is the documented "no upper bound" sentinel; keep it
|
|
912
|
+
// in the payload rather than stripping to undefined.
|
|
913
|
+
body.max_runtime_version = params.maxRuntimeVersion;
|
|
914
|
+
}
|
|
915
|
+
if (params.targetRuntimeVersion !== undefined) {
|
|
916
|
+
body.target_runtime_version = params.targetRuntimeVersion;
|
|
917
|
+
}
|
|
842
918
|
|
|
843
919
|
const doRequest = async (): Promise<Response> =>
|
|
844
920
|
fetch(`${resolvedUrl}/v1/migrations/signed-url/`, {
|
|
@@ -854,7 +930,7 @@ export async function platformRequestSignedUrl(
|
|
|
854
930
|
// lookup. For session-token callers, a 401 frequently means the
|
|
855
931
|
// cached org ID is stale — calling doRequest() again without clearing
|
|
856
932
|
// the cache would just send the same stale header and fail again.
|
|
857
|
-
|
|
933
|
+
invalidateOrgIdCache(token, platformUrl);
|
|
858
934
|
response = await doRequest();
|
|
859
935
|
}
|
|
860
936
|
|
|
@@ -873,9 +949,28 @@ export async function platformRequestSignedUrl(
|
|
|
873
949
|
};
|
|
874
950
|
}
|
|
875
951
|
|
|
952
|
+
// Non-success body. Read once and reuse for both the 422 version-mismatch
|
|
953
|
+
// branch and the generic-error fallthrough — `response.json()` consumes
|
|
954
|
+
// the body, so a second read would always return undefined.
|
|
876
955
|
const errorBody = (await response.json().catch(() => ({}))) as {
|
|
877
956
|
detail?: string;
|
|
957
|
+
reason?: string;
|
|
958
|
+
bundle_compat?: BundleCompatibility;
|
|
959
|
+
target_runtime_version?: string;
|
|
878
960
|
};
|
|
961
|
+
|
|
962
|
+
if (
|
|
963
|
+
response.status === 422 &&
|
|
964
|
+
errorBody.reason === "version_mismatch" &&
|
|
965
|
+
errorBody.bundle_compat &&
|
|
966
|
+
typeof errorBody.target_runtime_version === "string"
|
|
967
|
+
) {
|
|
968
|
+
throw new VersionMismatchError(
|
|
969
|
+
errorBody.bundle_compat,
|
|
970
|
+
errorBody.target_runtime_version,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
879
974
|
throw new Error(
|
|
880
975
|
errorBody.detail ??
|
|
881
976
|
`Failed to request signed URL: ${response.status} ${response.statusText}`,
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
+
import { homedir } from "os";
|
|
2
3
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
|
|
3
4
|
import { basename, dirname, join } from "path";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
|
-
getBaseDir,
|
|
7
7
|
getDaemonPidPath,
|
|
8
8
|
loadAllAssistants,
|
|
9
9
|
} from "./assistant-config.js";
|
|
@@ -77,7 +77,7 @@ export async function retireLocal(
|
|
|
77
77
|
// For named instances (instanceDir differs from the base directory),
|
|
78
78
|
// archive and remove the entire instance directory. For the default
|
|
79
79
|
// instance, archive only the .vellum subdirectory.
|
|
80
|
-
const isNamedInstance = resources.instanceDir !==
|
|
80
|
+
const isNamedInstance = resources.instanceDir !== homedir();
|
|
81
81
|
const dirToArchive = isNamedInstance ? resources.instanceDir : vellumDir;
|
|
82
82
|
|
|
83
83
|
// Move the data directory out of the way so the path is immediately available
|
package/src/lib/runtime-url.ts
CHANGED
|
@@ -28,3 +28,25 @@ export function resolveRuntimeMigrationUrl(
|
|
|
28
28
|
}
|
|
29
29
|
return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
|
|
34
|
+
* taking the assistant's topology into account.
|
|
35
|
+
*
|
|
36
|
+
* - For local/docker assistants, `runtimeUrl` is the loopback gateway and
|
|
37
|
+
* the runtime serves `/v1/<subpath>` directly.
|
|
38
|
+
* - For platform-managed (cloud="vellum") assistants the path is rewritten
|
|
39
|
+
* to the wildcard runtime proxy:
|
|
40
|
+
* `{platformUrl}/v1/assistants/<assistantId>/<subpath>`.
|
|
41
|
+
*
|
|
42
|
+
* The `subpath` is appended verbatim (e.g. `"identity"`).
|
|
43
|
+
*/
|
|
44
|
+
export function resolveRuntimeUrl(
|
|
45
|
+
entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
|
|
46
|
+
subpath: string,
|
|
47
|
+
): string {
|
|
48
|
+
if (entry.cloud === "vellum") {
|
|
49
|
+
return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/${subpath}`;
|
|
50
|
+
}
|
|
51
|
+
return `${entry.runtimeUrl}/v1/${subpath}`;
|
|
52
|
+
}
|