@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a
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/bun.lock +8 -0
- package/knip.json +5 -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__/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__/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/commands/client.ts +511 -11
- 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 +89 -17
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +16 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +15 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- 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 +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +20 -6
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -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
|
|
|
@@ -311,7 +362,291 @@ function findWebDistDir(): string | null {
|
|
|
311
362
|
return null;
|
|
312
363
|
}
|
|
313
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Locate the apps/web source directory for running the Vite dev server.
|
|
367
|
+
* Only works from a source checkout (not npm-installed).
|
|
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
|
+
|
|
314
642
|
async function runWebInterface(): Promise<void> {
|
|
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
|
+
|
|
315
650
|
const distDir = findWebDistDir();
|
|
316
651
|
if (!distDir) {
|
|
317
652
|
console.error(
|
|
@@ -323,7 +658,14 @@ async function runWebInterface(): Promise<void> {
|
|
|
323
658
|
process.exit(1);
|
|
324
659
|
}
|
|
325
660
|
|
|
326
|
-
const
|
|
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
|
+
);
|
|
327
669
|
|
|
328
670
|
const server = Bun.serve({
|
|
329
671
|
port: 3000,
|
|
@@ -332,10 +674,82 @@ async function runWebInterface(): Promise<void> {
|
|
|
332
674
|
const url = new URL(req.url);
|
|
333
675
|
const { pathname } = url;
|
|
334
676
|
|
|
335
|
-
if (pathname === "/") {
|
|
677
|
+
if (pathname === "/" || pathname === "/assistant") {
|
|
336
678
|
return Response.redirect(SPA_BASE, 302);
|
|
337
679
|
}
|
|
338
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
|
+
|
|
339
753
|
if (pathname.startsWith(SPA_BASE)) {
|
|
340
754
|
const relPath = pathname.slice(SPA_BASE.length);
|
|
341
755
|
if (relPath) {
|
|
@@ -350,6 +764,13 @@ async function runWebInterface(): Promise<void> {
|
|
|
350
764
|
});
|
|
351
765
|
}
|
|
352
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
|
+
|
|
353
774
|
return new Response("Not Found", { status: 404 });
|
|
354
775
|
},
|
|
355
776
|
});
|
|
@@ -368,6 +789,74 @@ async function runWebInterface(): Promise<void> {
|
|
|
368
789
|
await new Promise(() => {});
|
|
369
790
|
}
|
|
370
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);
|
|
811
|
+
};
|
|
812
|
+
process.on("SIGINT", shutdown);
|
|
813
|
+
process.on("SIGTERM", shutdown);
|
|
814
|
+
|
|
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;
|
|
858
|
+
}
|
|
859
|
+
|
|
371
860
|
export async function client(): Promise<void> {
|
|
372
861
|
const {
|
|
373
862
|
runtimeUrl,
|
|
@@ -415,8 +904,19 @@ export async function client(): Promise<void> {
|
|
|
415
904
|
...getClientRegistrationHeaders(interfaceId),
|
|
416
905
|
};
|
|
417
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
|
+
);
|
|
418
918
|
auth = {
|
|
419
|
-
...(
|
|
919
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
420
920
|
...getClientRegistrationHeaders(interfaceId),
|
|
421
921
|
};
|
|
422
922
|
}
|