@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.
Files changed (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -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
- function parseArgs(): ParsedArgs {
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
- // Platform-hosted assistants use a session token; local assistants use a guardian JWT.
139
- const platformToken =
140
- cloud === "vellum" ? (readPlatformToken() ?? undefined) : undefined;
141
- const bearerToken =
142
- cloud === "vellum"
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 indexHtml = await Bun.file(path.join(distDir, "index.html")).text();
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
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
919
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
420
920
  ...getClientRegistrationHeaders(interfaceId),
421
921
  };
422
922
  }