@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.
Files changed (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /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
 
@@ -278,18 +329,29 @@ async function maybeHydratePlatformAssistantName(
278
329
  }
279
330
  }
280
331
 
332
+ const SPA_BASE = "/assistant/";
333
+
281
334
  /**
282
- * Walk up from this file's location to find a sibling `clients/web` package.
335
+ * Locate the pre-built @vellumai/web dist directory.
283
336
  *
284
- * Returns the absolute path to its directory, or null when not found —
285
- * e.g. when the CLI is installed via npm/bunx, where the `clients/web`
286
- * source isn't shipped alongside `@vellumai/cli`. For now we treat the
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 findClientsWebDir(): string | null {
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, "clients", "web", "package.json");
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
- * Spawn the `clients/web` package's `local` script and proxy its lifecycle.
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
- const webDir = findClientsWebDir();
312
- if (!webDir) {
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
- `clients/web. This interface currently requires running ` +
316
- `vellum from a source checkout of vellum-assistant.`,
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 child = Bun.spawn({
322
- cmd: ["bun", "run", "local"],
323
- cwd: webDir,
324
- stdio: ["inherit", "inherit", "inherit"],
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
- const forward = (signal: "SIGINT" | "SIGTERM"): void => {
328
- try {
329
- child.kill(signal);
330
- } catch {
331
- // Child already exited; nothing to forward.
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", () => forward("SIGINT"));
335
- process.on("SIGTERM", () => forward("SIGTERM"));
812
+ process.on("SIGINT", shutdown);
813
+ process.on("SIGTERM", shutdown);
336
814
 
337
- const exitCode = await child.exited;
338
- process.exit(typeof exitCode === "number" ? exitCode : 0);
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
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
919
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
390
920
  ...getClientRegistrationHeaders(interfaceId),
391
921
  };
392
922
  }