@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356
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 +6 -0
- package/bun.lock +8 -0
- package/knip.json +6 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/backup.test.ts +38 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/recover.test.ts +307 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/__tests__/wake.test.ts +215 -0
- package/src/commands/backup.ts +2 -0
- package/src/commands/client.ts +569 -39
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +269 -0
- package/src/commands/gateway/token.ts +73 -0
- package/src/commands/gateway.ts +29 -0
- package/src/commands/logs.ts +6 -18
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +57 -41
- package/src/commands/recover.ts +47 -9
- package/src/commands/restore.ts +8 -1
- package/src/commands/retire.ts +23 -70
- package/src/commands/rollback.ts +2 -14
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh.ts +5 -24
- package/src/commands/teleport.ts +34 -26
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/upgrade.ts +8 -16
- package/src/commands/wake.ts +75 -45
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +22 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/__tests__/step-runner.test.ts +49 -1
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +28 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/config-utils.ts +24 -3
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +82 -8
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +75 -33
- package/src/lib/http-client.ts +1 -3
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +193 -298
- package/src/lib/orphan-detection.ts +9 -5
- package/src/lib/pgrep.ts +5 -1
- package/src/lib/platform-client.ts +97 -49
- package/src/lib/process.ts +109 -39
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- package/src/lib/step-runner.ts +67 -7
- package/src/lib/sync-cloud-assistants.ts +17 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
package/src/commands/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { hostname } from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -16,17 +17,35 @@ import {
|
|
|
16
17
|
GATEWAY_PORT,
|
|
17
18
|
type Species,
|
|
18
19
|
} from "../lib/constants";
|
|
19
|
-
import { loadGuardianToken } from "../lib/guardian-token";
|
|
20
|
+
import { loadGuardianToken, refreshGuardianToken } from "../lib/guardian-token";
|
|
20
21
|
import { getLocalLanIPv4 } from "../lib/local";
|
|
21
22
|
import {
|
|
22
23
|
CLI_INTERFACE_ID,
|
|
23
24
|
WEB_INTERFACE_ID,
|
|
24
25
|
getClientRegistrationHeaders,
|
|
25
26
|
} from "../lib/client-identity";
|
|
27
|
+
import {
|
|
28
|
+
getLockfileData,
|
|
29
|
+
upsertLockfileAssistant,
|
|
30
|
+
replacePlatformAssistants,
|
|
31
|
+
runHatch,
|
|
32
|
+
runRetire,
|
|
33
|
+
getGuardianAccessToken,
|
|
34
|
+
parseGatewayUrl,
|
|
35
|
+
resolveGatewayProxyTarget,
|
|
36
|
+
readAllowedGatewayPorts,
|
|
37
|
+
isLoopbackAddr,
|
|
38
|
+
resolveDevCliInvocation,
|
|
39
|
+
resolveLockfilePaths,
|
|
40
|
+
resolveConfigDir,
|
|
41
|
+
type CliInvocation,
|
|
42
|
+
} from "@vellumai/local-mode";
|
|
26
43
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
27
44
|
import {
|
|
28
45
|
fetchOrganizationId,
|
|
29
46
|
fetchPlatformAssistants,
|
|
47
|
+
getPlatformUrl,
|
|
48
|
+
getWebUrl,
|
|
30
49
|
readPlatformToken,
|
|
31
50
|
} from "../lib/platform-client";
|
|
32
51
|
import { tuiLog } from "../lib/tui-log";
|
|
@@ -64,7 +83,8 @@ function readAssistantName(entry: AssistantEntry | null): string | undefined {
|
|
|
64
83
|
: undefined;
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
|
|
86
|
+
// Exported for unit testing the arg/auth resolution without launching the TUI.
|
|
87
|
+
export function parseArgs(): ParsedArgs {
|
|
68
88
|
const args = process.argv.slice(3);
|
|
69
89
|
|
|
70
90
|
const positionalName = parseAssistantTargetArg(args, [
|
|
@@ -74,6 +94,8 @@ function parseArgs(): ParsedArgs {
|
|
|
74
94
|
"-a",
|
|
75
95
|
"--interface",
|
|
76
96
|
"-i",
|
|
97
|
+
"--token",
|
|
98
|
+
"-t",
|
|
77
99
|
]);
|
|
78
100
|
const flagArgs: string[] = [];
|
|
79
101
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -87,7 +109,9 @@ function parseArgs(): ParsedArgs {
|
|
|
87
109
|
arg === "--assistant-id" ||
|
|
88
110
|
arg === "-a" ||
|
|
89
111
|
arg === "--interface" ||
|
|
90
|
-
arg === "-i"
|
|
112
|
+
arg === "-i" ||
|
|
113
|
+
arg === "--token" ||
|
|
114
|
+
arg === "-t") &&
|
|
91
115
|
args[i + 1]
|
|
92
116
|
) {
|
|
93
117
|
flagArgs.push(arg, args[++i]);
|
|
@@ -135,11 +159,31 @@ function parseArgs(): ParsedArgs {
|
|
|
135
159
|
const cloud = entry?.cloud;
|
|
136
160
|
const species: Species = (entry?.species as Species) ?? "vellum";
|
|
137
161
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
// Ephemeral auth: a handed-over token (e.g. from `vellum pair`) used for this
|
|
163
|
+
// session only. Resolve it BEFORE the credential lookup below so an ephemeral
|
|
164
|
+
// session never reads (or writes) the local token store.
|
|
165
|
+
let bearerTokenOverride: string | undefined;
|
|
166
|
+
for (let i = 0; i < flagArgs.length; i++) {
|
|
167
|
+
if (
|
|
168
|
+
(flagArgs[i] === "--token" || flagArgs[i] === "-t") &&
|
|
169
|
+
flagArgs[i + 1]
|
|
170
|
+
) {
|
|
171
|
+
bearerTokenOverride = flagArgs[i + 1];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Platform-hosted assistants (cloud "vellum") use a session token; every
|
|
176
|
+
// other topology — local, docker, and "paired" (a remote assistant paired
|
|
177
|
+
// from another machine) — uses a bearer guardian JWT. Both are skipped
|
|
178
|
+
// entirely when --token supplies the credential, so no saved creds are read.
|
|
179
|
+
const platformToken = bearerTokenOverride
|
|
180
|
+
? undefined
|
|
181
|
+
: cloud === "vellum"
|
|
182
|
+
? (readPlatformToken() ?? undefined)
|
|
183
|
+
: undefined;
|
|
184
|
+
const bearerToken = bearerTokenOverride
|
|
185
|
+
? bearerTokenOverride
|
|
186
|
+
: cloud === "vellum"
|
|
143
187
|
? undefined
|
|
144
188
|
: (loadGuardianToken(entry?.assistantId ?? "")?.accessToken ?? undefined);
|
|
145
189
|
|
|
@@ -229,6 +273,9 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
|
|
|
229
273
|
|
|
230
274
|
${ANSI.bold}OPTIONS:${ANSI.reset}
|
|
231
275
|
-u, --url <url> Runtime URL
|
|
276
|
+
-t, --token <jwt> Bearer token to use for this session (e.g. from
|
|
277
|
+
'vellum pair'). Overrides the stored token and is
|
|
278
|
+
not persisted.
|
|
232
279
|
-a, --assistant-id <id> Assistant ID
|
|
233
280
|
-i, --interface <id> Interface identifier: cli (default) or web
|
|
234
281
|
-h, --help Show this help message
|
|
@@ -242,6 +289,10 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
|
|
|
242
289
|
vellum client vellum-assistant-foo
|
|
243
290
|
vellum client --url http://34.56.78.90:${GATEWAY_PORT}
|
|
244
291
|
vellum client vellum-assistant-foo --url http://localhost:${GATEWAY_PORT}
|
|
292
|
+
|
|
293
|
+
# Ephemeral: connect to another machine's assistant with a paired token
|
|
294
|
+
# (no lockfile entry, nothing persisted):
|
|
295
|
+
vellum client --url http://10.0.0.196:${GATEWAY_PORT} --token <jwt>
|
|
245
296
|
`);
|
|
246
297
|
}
|
|
247
298
|
|
|
@@ -278,18 +329,29 @@ async function maybeHydratePlatformAssistantName(
|
|
|
278
329
|
}
|
|
279
330
|
}
|
|
280
331
|
|
|
332
|
+
const SPA_BASE = "/assistant/";
|
|
333
|
+
|
|
281
334
|
/**
|
|
282
|
-
*
|
|
335
|
+
* Locate the pre-built @vellumai/web dist directory.
|
|
283
336
|
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* `--interface web` path as source-checkout-only.
|
|
337
|
+
* Resolution order:
|
|
338
|
+
* 1. npm-installed package — require.resolve('@vellumai/web/package.json')
|
|
339
|
+
* 2. Source checkout — walk up from cli/ to find apps/web/dist/
|
|
288
340
|
*/
|
|
289
|
-
function
|
|
341
|
+
function findWebDistDir(): string | null {
|
|
342
|
+
try {
|
|
343
|
+
const pkgPath = require.resolve("@vellumai/web/package.json");
|
|
344
|
+
const distDir = path.join(path.dirname(pkgPath), "dist");
|
|
345
|
+
if (existsSync(path.join(distDir, "index.html"))) {
|
|
346
|
+
return distDir;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Package not installed; try source checkout.
|
|
350
|
+
}
|
|
351
|
+
|
|
290
352
|
let dir = import.meta.dir;
|
|
291
353
|
for (let depth = 0; depth < 8; depth++) {
|
|
292
|
-
const candidate = path.join(dir, "
|
|
354
|
+
const candidate = path.join(dir, "apps", "web", "dist", "index.html");
|
|
293
355
|
if (existsSync(candidate)) {
|
|
294
356
|
return path.dirname(candidate);
|
|
295
357
|
}
|
|
@@ -301,41 +363,498 @@ function findClientsWebDir(): string | null {
|
|
|
301
363
|
}
|
|
302
364
|
|
|
303
365
|
/**
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* The web client is deliberately not declared as a dependency of `@vellumai/cli`:
|
|
307
|
-
* the CLI is published, the web package is not. Locating it on disk and
|
|
308
|
-
* shelling out keeps the two packages independent.
|
|
366
|
+
* Locate the apps/web source directory for running the Vite dev server.
|
|
367
|
+
* Only works from a source checkout (not npm-installed).
|
|
309
368
|
*/
|
|
369
|
+
function findWebSourceDir(): string | null {
|
|
370
|
+
let dir = import.meta.dir;
|
|
371
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
372
|
+
const candidate = path.join(dir, "apps", "web", "vite.config.ts");
|
|
373
|
+
if (existsSync(candidate)) {
|
|
374
|
+
return path.dirname(candidate);
|
|
375
|
+
}
|
|
376
|
+
const parent = path.dirname(dir);
|
|
377
|
+
if (parent === dir) break;
|
|
378
|
+
dir = parent;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const LOCKFILE_PATTERN = /^(?:\/assistant)?\/__local\/lockfile$/;
|
|
384
|
+
const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
|
|
385
|
+
const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
|
|
386
|
+
const GUARDIAN_TOKEN_PATTERN =
|
|
387
|
+
/^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
|
|
388
|
+
|
|
389
|
+
function getEnvRecord(): Record<string, string> {
|
|
390
|
+
const result: Record<string, string> = {};
|
|
391
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
392
|
+
if (v !== undefined) result[k] = v;
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const _localEnv = getEnvRecord();
|
|
398
|
+
const _lockfilePaths = resolveLockfilePaths(_localEnv);
|
|
399
|
+
const _configDir = resolveConfigDir(_localEnv);
|
|
400
|
+
const _baseDir = getBaseDir();
|
|
401
|
+
|
|
402
|
+
async function handleLocalEndpoints(
|
|
403
|
+
req: Request,
|
|
404
|
+
url: URL,
|
|
405
|
+
server: { requestIP(req: Request): { address: string } | null },
|
|
406
|
+
): Promise<Response | null> {
|
|
407
|
+
const { pathname } = url;
|
|
408
|
+
const lockfilePaths = _lockfilePaths;
|
|
409
|
+
const configDir = _configDir;
|
|
410
|
+
|
|
411
|
+
// Check if this is a __local or __gateway route before enforcing loopback.
|
|
412
|
+
const isLocalRoute =
|
|
413
|
+
LOCKFILE_PATTERN.test(pathname) ||
|
|
414
|
+
HATCH_PATTERN.test(pathname) ||
|
|
415
|
+
RETIRE_PATTERN.test(pathname) ||
|
|
416
|
+
GUARDIAN_TOKEN_PATTERN.test(pathname) ||
|
|
417
|
+
parseGatewayUrl(pathname).match;
|
|
418
|
+
|
|
419
|
+
if (!isLocalRoute) return null;
|
|
420
|
+
|
|
421
|
+
// All __local and __gateway endpoints are restricted to loopback clients.
|
|
422
|
+
const peer = server.requestIP(req)?.address ?? "";
|
|
423
|
+
if (!isLoopbackAddr(peer)) {
|
|
424
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Lockfile
|
|
428
|
+
if (LOCKFILE_PATTERN.test(pathname)) {
|
|
429
|
+
if (req.method === "GET") {
|
|
430
|
+
const result = getLockfileData(lockfilePaths);
|
|
431
|
+
if (result.ok) {
|
|
432
|
+
return Response.json(result.data);
|
|
433
|
+
}
|
|
434
|
+
return new Response(null, { status: result.status });
|
|
435
|
+
}
|
|
436
|
+
if (req.method === "POST") {
|
|
437
|
+
let body: Record<string, unknown>;
|
|
438
|
+
try {
|
|
439
|
+
body = (await req.json()) as Record<string, unknown>;
|
|
440
|
+
} catch {
|
|
441
|
+
return Response.json(
|
|
442
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
443
|
+
{ status: 400 },
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
let result;
|
|
447
|
+
if (body.syncPlatform && Array.isArray(body.platformAssistants)) {
|
|
448
|
+
result = replacePlatformAssistants(
|
|
449
|
+
lockfilePaths,
|
|
450
|
+
body.platformAssistants as Array<Record<string, unknown>>,
|
|
451
|
+
);
|
|
452
|
+
} else {
|
|
453
|
+
result = upsertLockfileAssistant(
|
|
454
|
+
lockfilePaths,
|
|
455
|
+
body.assistant as Record<string, unknown>,
|
|
456
|
+
body.activeAssistant as string | undefined,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return Response.json(result, { status: result.ok ? 200 : result.status });
|
|
460
|
+
}
|
|
461
|
+
return new Response(null, { status: 405 });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Hatch
|
|
465
|
+
if (HATCH_PATTERN.test(pathname)) {
|
|
466
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
467
|
+
|
|
468
|
+
let species = "vellum";
|
|
469
|
+
let remote: string | undefined;
|
|
470
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
471
|
+
if (contentType.includes("json")) {
|
|
472
|
+
try {
|
|
473
|
+
const body = (await req.json()) as {
|
|
474
|
+
species?: string;
|
|
475
|
+
remote?: string;
|
|
476
|
+
};
|
|
477
|
+
if (body.species) species = body.species;
|
|
478
|
+
if (body.remote) remote = body.remote;
|
|
479
|
+
} catch {
|
|
480
|
+
return Response.json(
|
|
481
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
482
|
+
{ status: 400 },
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let invocation: CliInvocation;
|
|
488
|
+
try {
|
|
489
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
return Response.json(
|
|
492
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
493
|
+
{ status: 500 },
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const result = await runHatch(
|
|
498
|
+
invocation,
|
|
499
|
+
species,
|
|
500
|
+
remote ? { remote } : undefined,
|
|
501
|
+
);
|
|
502
|
+
if (result.ok) {
|
|
503
|
+
return Response.json({ ok: true, assistantId: result.assistantId });
|
|
504
|
+
}
|
|
505
|
+
return Response.json(
|
|
506
|
+
{ ok: false, error: result.error },
|
|
507
|
+
{ status: result.status },
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Retire
|
|
512
|
+
if (RETIRE_PATTERN.test(pathname)) {
|
|
513
|
+
if (req.method !== "POST") return new Response(null, { status: 405 });
|
|
514
|
+
|
|
515
|
+
let assistantId: string | undefined;
|
|
516
|
+
try {
|
|
517
|
+
const body = (await req.json()) as { assistantId?: string };
|
|
518
|
+
assistantId = body.assistantId;
|
|
519
|
+
} catch {
|
|
520
|
+
return Response.json(
|
|
521
|
+
{ ok: false, error: "Invalid JSON body" },
|
|
522
|
+
{ status: 400 },
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!assistantId) {
|
|
527
|
+
return Response.json(
|
|
528
|
+
{ ok: false, error: "Missing assistantId" },
|
|
529
|
+
{ status: 400 },
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let invocation: CliInvocation;
|
|
534
|
+
try {
|
|
535
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
return Response.json(
|
|
538
|
+
{ ok: false, error: err instanceof Error ? err.message : String(err) },
|
|
539
|
+
{ status: 500 },
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const result = await runRetire(invocation, assistantId);
|
|
544
|
+
if (result.ok) {
|
|
545
|
+
return Response.json({ ok: true });
|
|
546
|
+
}
|
|
547
|
+
return Response.json(
|
|
548
|
+
{ ok: false, error: result.error },
|
|
549
|
+
{ status: result.status },
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Guardian token
|
|
554
|
+
const guardianMatch = pathname.match(GUARDIAN_TOKEN_PATTERN);
|
|
555
|
+
if (guardianMatch) {
|
|
556
|
+
if (req.method !== "GET") return new Response(null, { status: 405 });
|
|
557
|
+
|
|
558
|
+
const assistantId = decodeURIComponent(guardianMatch[1]!);
|
|
559
|
+
|
|
560
|
+
let invocation: CliInvocation;
|
|
561
|
+
try {
|
|
562
|
+
invocation = resolveDevCliInvocation(_baseDir);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
return Response.json(
|
|
565
|
+
{ error: err instanceof Error ? err.message : String(err) },
|
|
566
|
+
{ status: 500 },
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const result = await getGuardianAccessToken(
|
|
571
|
+
assistantId,
|
|
572
|
+
configDir,
|
|
573
|
+
invocation,
|
|
574
|
+
true,
|
|
575
|
+
_localEnv,
|
|
576
|
+
);
|
|
577
|
+
if (result.ok) {
|
|
578
|
+
return Response.json({ accessToken: result.accessToken });
|
|
579
|
+
}
|
|
580
|
+
return Response.json({ error: result.error }, { status: result.status });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Gateway proxy — same allowlist decision the web (Vite middleware) and
|
|
584
|
+
// Electron (`app://` handler) hosts use, so all three can't drift.
|
|
585
|
+
const gatewayDecision = resolveGatewayProxyTarget(pathname, () =>
|
|
586
|
+
readAllowedGatewayPorts(lockfilePaths),
|
|
587
|
+
);
|
|
588
|
+
if (gatewayDecision.kind === "invalid-port") {
|
|
589
|
+
return new Response("Port must be between 1024 and 65535", { status: 400 });
|
|
590
|
+
}
|
|
591
|
+
if (gatewayDecision.kind === "forbidden-port") {
|
|
592
|
+
return new Response("Gateway port is not active in lockfile", {
|
|
593
|
+
status: 403,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (gatewayDecision.kind === "forward") {
|
|
597
|
+
const { target: gatewayTarget } = gatewayDecision;
|
|
598
|
+
const targetUrl = `http://127.0.0.1:${gatewayTarget.port}${gatewayTarget.path}${url.search}`;
|
|
599
|
+
const headers = new Headers(req.headers);
|
|
600
|
+
headers.set("host", `127.0.0.1:${gatewayTarget.port}`);
|
|
601
|
+
|
|
602
|
+
try {
|
|
603
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
604
|
+
const proxyRes = await fetch(targetUrl, {
|
|
605
|
+
method: req.method,
|
|
606
|
+
headers,
|
|
607
|
+
body: hasBody ? req.body : undefined,
|
|
608
|
+
redirect: "manual",
|
|
609
|
+
});
|
|
610
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
611
|
+
resHeaders.delete("transfer-encoding");
|
|
612
|
+
return new Response(proxyRes.body, {
|
|
613
|
+
status: proxyRes.status,
|
|
614
|
+
statusText: proxyRes.statusText,
|
|
615
|
+
headers: resHeaders,
|
|
616
|
+
});
|
|
617
|
+
} catch {
|
|
618
|
+
return new Response("Gateway proxy error", { status: 502 });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function getBaseDir(): string {
|
|
626
|
+
let dir = import.meta.dir;
|
|
627
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
628
|
+
if (existsSync(path.join(dir, "cli", "src", "index.ts"))) {
|
|
629
|
+
return dir;
|
|
630
|
+
}
|
|
631
|
+
const pkgPath = path.join(dir, "package.json");
|
|
632
|
+
if (existsSync(pkgPath)) {
|
|
633
|
+
return dir;
|
|
634
|
+
}
|
|
635
|
+
const parent = path.dirname(dir);
|
|
636
|
+
if (parent === dir) break;
|
|
637
|
+
dir = parent;
|
|
638
|
+
}
|
|
639
|
+
return path.resolve(import.meta.dir, "..", "..", "..");
|
|
640
|
+
}
|
|
641
|
+
|
|
310
642
|
async function runWebInterface(): Promise<void> {
|
|
311
|
-
|
|
312
|
-
|
|
643
|
+
// Prefer Vite dev server in source checkouts for full local-mode support
|
|
644
|
+
// (HMR, __local endpoints, gateway proxy).
|
|
645
|
+
const webSourceDir = findWebSourceDir();
|
|
646
|
+
if (webSourceDir) {
|
|
647
|
+
return runViteDevServer(webSourceDir);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const distDir = findWebDistDir();
|
|
651
|
+
if (!distDir) {
|
|
313
652
|
console.error(
|
|
314
653
|
`${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
|
|
315
|
-
|
|
316
|
-
`
|
|
654
|
+
`@vellumai/web assets.\n\n` +
|
|
655
|
+
` npm/bunx install: npm install @vellumai/web\n` +
|
|
656
|
+
` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
|
|
317
657
|
);
|
|
318
658
|
process.exit(1);
|
|
319
659
|
}
|
|
320
660
|
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
661
|
+
const rawIndexHtml = await Bun.file(path.join(distDir, "index.html")).text();
|
|
662
|
+
const platformUrl = getPlatformUrl();
|
|
663
|
+
const webUrl = getWebUrl();
|
|
664
|
+
const configJson = JSON.stringify({ webUrl, platformUrl });
|
|
665
|
+
const indexHtml = rawIndexHtml.replace(
|
|
666
|
+
"</head>",
|
|
667
|
+
`<script>window.__VELLUM_CONFIG__=${configJson}</script></head>`,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const server = Bun.serve({
|
|
671
|
+
port: 3000,
|
|
672
|
+
hostname: "127.0.0.1",
|
|
673
|
+
fetch: async (req) => {
|
|
674
|
+
const url = new URL(req.url);
|
|
675
|
+
const { pathname } = url;
|
|
676
|
+
|
|
677
|
+
if (pathname === "/" || pathname === "/assistant") {
|
|
678
|
+
return Response.redirect(SPA_BASE, 302);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Loopback auth: the platform redirects here after login with
|
|
682
|
+
// ?state=...&session_token=... — forward into the SPA.
|
|
683
|
+
if (pathname === "/callback") {
|
|
684
|
+
return Response.redirect(
|
|
685
|
+
`/account/platform-callback${url.search}`,
|
|
686
|
+
302,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Expose environment config to the SPA.
|
|
691
|
+
if (pathname === "/assistant/__config" || pathname === "/__config") {
|
|
692
|
+
return new Response(configJson, {
|
|
693
|
+
headers: { "Content-Type": "application/json" },
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
|
|
698
|
+
const localResponse = await handleLocalEndpoints(req, url, server);
|
|
699
|
+
if (localResponse) return localResponse;
|
|
700
|
+
|
|
701
|
+
// Reverse-proxy platform API requests.
|
|
702
|
+
if (
|
|
703
|
+
pathname.startsWith("/v1/") ||
|
|
704
|
+
pathname.startsWith("/_allauth/") ||
|
|
705
|
+
pathname.startsWith("/accounts/")
|
|
706
|
+
) {
|
|
707
|
+
const target = new URL(pathname + url.search, platformUrl);
|
|
708
|
+
const headers = new Headers(req.headers);
|
|
709
|
+
headers.set("Host", new URL(platformUrl).host);
|
|
710
|
+
headers.delete("Origin");
|
|
711
|
+
headers.delete("Referer");
|
|
712
|
+
|
|
713
|
+
// Forward the session token — the loopback flow stores it in
|
|
714
|
+
// the browser cookie jar for localhost, but the platform backend
|
|
715
|
+
// expects it on its own domain. Set both the Cookie (for Django
|
|
716
|
+
// session middleware / allauth) and X-Session-Token (for DRF
|
|
717
|
+
// views that accept header-based auth).
|
|
718
|
+
const sessionToken = /sessionid=([^;]+)/.exec(
|
|
719
|
+
req.headers.get("Cookie") ?? "",
|
|
720
|
+
)?.[1];
|
|
721
|
+
if (sessionToken) {
|
|
722
|
+
headers.set(
|
|
723
|
+
"Cookie",
|
|
724
|
+
`sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
|
|
725
|
+
);
|
|
726
|
+
headers.set("X-Session-Token", sessionToken);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
|
731
|
+
const body = hasBody ? await req.arrayBuffer() : undefined;
|
|
732
|
+
const proxyRes = await fetch(target.toString(), {
|
|
733
|
+
method: req.method,
|
|
734
|
+
headers,
|
|
735
|
+
body,
|
|
736
|
+
redirect: "manual",
|
|
737
|
+
});
|
|
738
|
+
const resHeaders = new Headers(proxyRes.headers);
|
|
739
|
+
resHeaders.delete("transfer-encoding");
|
|
740
|
+
return new Response(proxyRes.body, {
|
|
741
|
+
status: proxyRes.status,
|
|
742
|
+
statusText: proxyRes.statusText,
|
|
743
|
+
headers: resHeaders,
|
|
744
|
+
});
|
|
745
|
+
} catch (err) {
|
|
746
|
+
return new Response(
|
|
747
|
+
JSON.stringify({ error: `Platform proxy error: ${err}` }),
|
|
748
|
+
{ status: 502, headers: { "Content-Type": "application/json" } },
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (pathname.startsWith(SPA_BASE)) {
|
|
754
|
+
const relPath = pathname.slice(SPA_BASE.length);
|
|
755
|
+
if (relPath) {
|
|
756
|
+
const filePath = path.join(distDir, relPath);
|
|
757
|
+
const file = Bun.file(filePath);
|
|
758
|
+
if (await file.exists()) {
|
|
759
|
+
return new Response(file);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return new Response(indexHtml, {
|
|
763
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// SPA fallback for /account/* routes (login, callback, etc.)
|
|
768
|
+
if (pathname.startsWith("/account/")) {
|
|
769
|
+
return new Response(indexHtml, {
|
|
770
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return new Response("Not Found", { status: 404 });
|
|
775
|
+
},
|
|
325
776
|
});
|
|
326
777
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
778
|
+
console.log(
|
|
779
|
+
`Vellum web interface: http://${server.hostname}:${server.port}${SPA_BASE}`,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
const shutdown = (): void => {
|
|
783
|
+
server.stop();
|
|
784
|
+
process.exit(0);
|
|
785
|
+
};
|
|
786
|
+
process.on("SIGINT", shutdown);
|
|
787
|
+
process.on("SIGTERM", shutdown);
|
|
788
|
+
|
|
789
|
+
await new Promise(() => {});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function runViteDevServer(webSourceDir: string): Promise<void> {
|
|
793
|
+
const platformUrl = getPlatformUrl();
|
|
794
|
+
|
|
795
|
+
const child = spawn("bun", ["run", "dev"], {
|
|
796
|
+
cwd: webSourceDir,
|
|
797
|
+
stdio: "inherit",
|
|
798
|
+
env: {
|
|
799
|
+
...process.env,
|
|
800
|
+
VITE_PLATFORM_MODE: "false",
|
|
801
|
+
API_PROXY_TARGET: platformUrl,
|
|
802
|
+
VELLUM_WEB_URL: getWebUrl(),
|
|
803
|
+
VELLUM_PLATFORM_URL: platformUrl,
|
|
804
|
+
PORT: "3000",
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const shutdown = (): void => {
|
|
809
|
+
child.kill();
|
|
810
|
+
process.exit(0);
|
|
333
811
|
};
|
|
334
|
-
process.on("SIGINT",
|
|
335
|
-
process.on("SIGTERM",
|
|
812
|
+
process.on("SIGINT", shutdown);
|
|
813
|
+
process.on("SIGTERM", shutdown);
|
|
336
814
|
|
|
337
|
-
|
|
338
|
-
|
|
815
|
+
await new Promise<void>((_, reject) => {
|
|
816
|
+
child.on("exit", (code) => {
|
|
817
|
+
if (code !== 0) {
|
|
818
|
+
reject(new Error(`Vite dev server exited with code ${code}`));
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Return a possibly-refreshed bearer token for the TUI's startup auth.
|
|
826
|
+
*
|
|
827
|
+
* Only a STORED guardian token is refreshable: platform session auth
|
|
828
|
+
* (`cloud === "vellum"`) and ephemeral `--token` overrides (whose token won't
|
|
829
|
+
* match the store) are left untouched, as is a token that's still fresh. When
|
|
830
|
+
* the stored token has passed its `refreshAfter` (or expiry) and a refresh
|
|
831
|
+
* token is available, refresh once via the concurrency-safe refreshGuardianToken
|
|
832
|
+
* and use the rotated access token. Falls back to the existing token if refresh
|
|
833
|
+
* isn't possible/fails — the session still starts (same as before).
|
|
834
|
+
*/
|
|
835
|
+
export async function resolveFreshBearerToken(
|
|
836
|
+
runtimeUrl: string,
|
|
837
|
+
assistantId: string,
|
|
838
|
+
bearerToken: string | undefined,
|
|
839
|
+
cloud: string | undefined,
|
|
840
|
+
): Promise<string | undefined> {
|
|
841
|
+
if (cloud === "vellum" || !bearerToken || !assistantId) return bearerToken;
|
|
842
|
+
|
|
843
|
+
const stored = loadGuardianToken(assistantId);
|
|
844
|
+
// Refresh only the stored token (an ephemeral --token won't match), and only
|
|
845
|
+
// when a refresh credential is present.
|
|
846
|
+
if (!stored || stored.accessToken !== bearerToken || !stored.refreshToken) {
|
|
847
|
+
return bearerToken;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// new Date() handles both ISO strings and epoch-ms numbers; Date.parse of an
|
|
851
|
+
// epoch-ms string would be NaN.
|
|
852
|
+
const renewAtRaw = stored.refreshAfter || stored.accessTokenExpiresAt;
|
|
853
|
+
const renewAt = new Date(renewAtRaw).getTime();
|
|
854
|
+
if (!Number.isFinite(renewAt) || renewAt > Date.now()) return bearerToken;
|
|
855
|
+
|
|
856
|
+
const refreshed = await refreshGuardianToken(runtimeUrl, assistantId);
|
|
857
|
+
return refreshed?.accessToken ?? bearerToken;
|
|
339
858
|
}
|
|
340
859
|
|
|
341
860
|
export async function client(): Promise<void> {
|
|
@@ -385,8 +904,19 @@ export async function client(): Promise<void> {
|
|
|
385
904
|
...getClientRegistrationHeaders(interfaceId),
|
|
386
905
|
};
|
|
387
906
|
} else {
|
|
907
|
+
// Proactively refresh a stale STORED guardian token before opening the TUI,
|
|
908
|
+
// so launching after the access token expired renews transparently rather
|
|
909
|
+
// than erroring. (Mid-session expiry — the token dying while the TUI is
|
|
910
|
+
// already open — is a separate follow-up, since the TUI threads a static
|
|
911
|
+
// auth object through React.)
|
|
912
|
+
const token = await resolveFreshBearerToken(
|
|
913
|
+
runtimeUrl,
|
|
914
|
+
assistantId,
|
|
915
|
+
bearerToken,
|
|
916
|
+
cloud,
|
|
917
|
+
);
|
|
388
918
|
auth = {
|
|
389
|
-
...(
|
|
919
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
390
920
|
...getClientRegistrationHeaders(interfaceId),
|
|
391
921
|
};
|
|
392
922
|
}
|