@vellumai/cli 0.10.0-dev.202606230105.081b3b9 → 0.10.0-dev.202606230530.bc0f32e

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.
@@ -69,6 +69,32 @@ describe("isActiveAssistant", () => {
69
69
  expect(isActiveAssistant([lockfilePath], "active")).toBe(true);
70
70
  });
71
71
 
72
+ test("returns true for the sole assistant when activeAssistant is empty", () => {
73
+ const dir = makeTempDir();
74
+ const lockfilePath = path.join(dir, "lockfile.json");
75
+ fs.writeFileSync(
76
+ lockfilePath,
77
+ JSON.stringify({
78
+ assistants: [{ assistantId: "only" }],
79
+ activeAssistant: null,
80
+ }),
81
+ );
82
+ expect(isActiveAssistant([lockfilePath], "only")).toBe(true);
83
+ });
84
+
85
+ test("returns true for the sole assistant when activeAssistant is stale", () => {
86
+ const dir = makeTempDir();
87
+ const lockfilePath = path.join(dir, "lockfile.json");
88
+ fs.writeFileSync(
89
+ lockfilePath,
90
+ JSON.stringify({
91
+ assistants: [{ assistantId: "only" }],
92
+ activeAssistant: "missing",
93
+ }),
94
+ );
95
+ expect(isActiveAssistant([lockfilePath], "only")).toBe(true);
96
+ });
97
+
72
98
  test("returns false for a non-active assistant", () => {
73
99
  const dir = makeTempDir();
74
100
  const lockfilePath = path.join(dir, "lockfile.json");
@@ -94,8 +94,15 @@ export function isActiveAssistant(
94
94
  ): boolean {
95
95
  for (const candidate of lockfilePaths) {
96
96
  try {
97
- const data = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<string, unknown>;
98
- return data.activeAssistant === assistantId;
97
+ const data = JSON.parse(fs.readFileSync(candidate, "utf-8")) as Record<
98
+ string,
99
+ unknown
100
+ >;
101
+ if (data.activeAssistant === assistantId) return true;
102
+ const assistants = data.assistants;
103
+ if (!Array.isArray(assistants) || assistants.length !== 1) return false;
104
+ const [onlyAssistant] = assistants as Array<Record<string, unknown>>;
105
+ return onlyAssistant?.assistantId === assistantId;
99
106
  } catch {
100
107
  continue;
101
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.0-dev.202606230105.081b3b9",
3
+ "version": "0.10.0-dev.202606230530.bc0f32e",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -54,9 +54,12 @@ import {
54
54
  getPlatformUrl,
55
55
  getWebUrl,
56
56
  readPlatformToken,
57
+ savePlatformToken,
58
+ clearPlatformToken,
57
59
  } from "../lib/platform-client";
58
60
  import { tuiLog } from "../lib/tui-log";
59
61
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
62
+ import { probePort } from "../lib/port-probe.js";
60
63
 
61
64
  const SUPPORTED_INTERFACES = ["cli", "web"] as const;
62
65
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
@@ -101,8 +104,10 @@ export function parseArgs(): ParsedArgs {
101
104
  const { envVars: cliFlagVars, remaining: argsWithoutFlags } =
102
105
  parseFeatureFlagArgs(process.argv.slice(3));
103
106
  const flagEnvVars = { ...readAmbientFlagEnvVars(), ...cliFlagVars };
104
- const disablePlatformAmbient = process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
105
- let disablePlatform = disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
107
+ const disablePlatformAmbient =
108
+ process.env.VELLUM_DISABLE_PLATFORM?.trim().toLowerCase();
109
+ let disablePlatform =
110
+ disablePlatformAmbient === "true" || disablePlatformAmbient === "1";
106
111
  const args = argsWithoutFlags;
107
112
 
108
113
  // Build parsedFlagOverrides from the extracted env vars:
@@ -389,6 +394,31 @@ const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
389
394
  const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
390
395
  const GUARDIAN_TOKEN_PATTERN =
391
396
  /^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
397
+ const PLATFORM_SESSION_PATTERN =
398
+ /^(?:\/assistant)?\/__local\/platform-session$/;
399
+
400
+ // The loopback platform session token. Persisted via the same store the CLI
401
+ // uses (so `vellum client` restarts and CLI logins stay in sync), cached here
402
+ // to keep it off the per-request proxy path. Set only after the SPA validates
403
+ // the loopback `state`, so an unsolicited /callback can't fixate a session.
404
+ let platformSessionToken: string | null | undefined;
405
+ function currentPlatformToken(): string | null {
406
+ if (platformSessionToken === undefined) {
407
+ platformSessionToken = readPlatformToken();
408
+ }
409
+ return platformSessionToken;
410
+ }
411
+
412
+ // Whether to attach the platform credential to a proxied request. Only
413
+ // same-origin (SPA) traffic qualifies — a cross-site page must not be able to
414
+ // use the local proxy as a confused deputy for authenticated platform calls.
415
+ // Cross-origin fetches always send an Origin; `Sec-Fetch-Site` is a belt-and-
416
+ // braces check for browsers that send it.
417
+ function isSameOriginRequest(req: Request): boolean {
418
+ if (!originIsAllowed(req.headers.get("origin") ?? undefined)) return false;
419
+ const site = req.headers.get("sec-fetch-site");
420
+ return !site || site === "same-origin" || site === "none";
421
+ }
392
422
 
393
423
  function getEnvRecord(): Record<string, string> {
394
424
  const result: Record<string, string> = {};
@@ -418,6 +448,7 @@ async function handleLocalEndpoints(
418
448
  HATCH_PATTERN.test(pathname) ||
419
449
  RETIRE_PATTERN.test(pathname) ||
420
450
  GUARDIAN_TOKEN_PATTERN.test(pathname) ||
451
+ PLATFORM_SESSION_PATTERN.test(pathname) ||
421
452
  parseGatewayUrl(pathname).match;
422
453
 
423
454
  if (!isLocalRoute) return null;
@@ -435,6 +466,33 @@ async function handleLocalEndpoints(
435
466
  return Response.json({ error: "Forbidden" }, { status: 403 });
436
467
  }
437
468
 
469
+ // Platform session: the SPA hands over the loopback token here (after it has
470
+ // validated the `state` nonce) so the proxy below can authenticate to the
471
+ // platform. The browser never holds a session cookie.
472
+ if (PLATFORM_SESSION_PATTERN.test(pathname)) {
473
+ if (req.method === "DELETE") {
474
+ clearPlatformToken();
475
+ platformSessionToken = null;
476
+ return Response.json({ ok: true });
477
+ }
478
+ if (req.method === "POST") {
479
+ const body = (await req.json().catch(() => null)) as {
480
+ token?: unknown;
481
+ } | null;
482
+ const token = body?.token;
483
+ if (typeof token !== "string" || !/^[A-Za-z0-9]+$/.test(token)) {
484
+ return Response.json(
485
+ { ok: false, error: "Invalid token" },
486
+ { status: 400 },
487
+ );
488
+ }
489
+ savePlatformToken(token);
490
+ platformSessionToken = token;
491
+ return Response.json({ ok: true });
492
+ }
493
+ return new Response(null, { status: 405 });
494
+ }
495
+
438
496
  // Lockfile
439
497
  if (LOCKFILE_PATTERN.test(pathname)) {
440
498
  if (req.method === "GET") {
@@ -658,6 +716,106 @@ function getBaseDir(): string {
658
716
  return path.resolve(import.meta.dir, "..", "..", "..");
659
717
  }
660
718
 
719
+ // Just the slice of a Bun server `fetchHandler` needs — matches the structural
720
+ // arg `handleLocalEndpoints` accepts, so Bun's `Server` is assignable to it.
721
+ type RequestPeerServer = {
722
+ requestIP(req: Request): { address: string } | null;
723
+ };
724
+
725
+ const WEB_PORT_SCAN_LIMIT = 50;
726
+
727
+ type WebFetchHandler = (
728
+ req: Request,
729
+ server: RequestPeerServer,
730
+ ) => Promise<Response>;
731
+
732
+ function isAddrInUse(err: unknown): boolean {
733
+ const e = err as { code?: string; message?: string } | undefined;
734
+ return (
735
+ e?.code === "EADDRINUSE" ||
736
+ /EADDRINUSE|address already in use/i.test(e?.message ?? "")
737
+ );
738
+ }
739
+
740
+ // Bind one loopback family; returns the server, or null when the port is in
741
+ // use. Server type is inferred from `Bun.serve` (avoids a generic mismatch).
742
+ function tryBindLoopback(
743
+ port: number,
744
+ hostname: string,
745
+ fetchHandler: WebFetchHandler,
746
+ ) {
747
+ try {
748
+ return Bun.serve({ port, hostname, fetch: fetchHandler });
749
+ } catch (err) {
750
+ if (isAddrInUse(err)) return null;
751
+ throw err;
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Bind the local web server on BOTH loopback families (`127.0.0.1` and `::1`)
757
+ * so the app can be reached at `http://localhost:<port>` regardless of whether
758
+ * the browser resolves `localhost` to IPv4 or IPv6 — matching the host the
759
+ * platform hardcodes in its loopback login callback.
760
+ *
761
+ * IPv4 is mandatory. IPv6 is best-effort: if `::1` is already taken (e.g. the
762
+ * local platform's `vel up` edge-proxy owns `[::]:<port>`), the port is
763
+ * contested and we advance — otherwise `localhost` would resolve to that other
764
+ * server. If IPv6 is simply unavailable on the host, we proceed IPv4-only.
765
+ *
766
+ * Never binds wildcard interfaces (`0.0.0.0`/`::`): the server exposes
767
+ * `/__local/*` control endpoints, so it must stay loopback-only.
768
+ */
769
+ function serveLoopback(preferredPort: number, fetchHandler: WebFetchHandler) {
770
+ for (
771
+ let port = preferredPort;
772
+ port < preferredPort + WEB_PORT_SCAN_LIMIT;
773
+ port++
774
+ ) {
775
+ const primary = tryBindLoopback(port, "127.0.0.1", fetchHandler);
776
+ if (!primary) continue;
777
+
778
+ try {
779
+ const secondary = Bun.serve({
780
+ port,
781
+ hostname: "::1",
782
+ fetch: fetchHandler,
783
+ });
784
+ return { port, servers: [primary, secondary] };
785
+ } catch (err) {
786
+ if (isAddrInUse(err)) {
787
+ // `::1` is contested (e.g. `vel up`) — move ports so `localhost`
788
+ // doesn't resolve to that other server.
789
+ primary.stop(true);
790
+ continue;
791
+ }
792
+ // IPv6 unavailable (e.g. EADDRNOTAVAIL) — IPv4-only is acceptable since
793
+ // `localhost` then resolves to 127.0.0.1 anyway.
794
+ return { port, servers: [primary] };
795
+ }
796
+ }
797
+ throw new Error(
798
+ `Could not bind a free loopback port in [${preferredPort}, ${preferredPort + WEB_PORT_SCAN_LIMIT - 1}]`,
799
+ );
800
+ }
801
+
802
+ /**
803
+ * Find the first port at/above `preferred` with nothing listening on either
804
+ * loopback family. Used for the Vite dev server, which binds the port itself
805
+ * (via the `PORT` env). Connect-probe based, so there's a small TOCTOU window
806
+ * before Vite binds — acceptable for dev.
807
+ */
808
+ async function findFreeDualLoopbackPort(preferred: number): Promise<number> {
809
+ for (let port = preferred; port < preferred + WEB_PORT_SCAN_LIMIT; port++) {
810
+ const [busyV4, busyV6] = await Promise.all([
811
+ probePort(port, "127.0.0.1"),
812
+ probePort(port, "::1"),
813
+ ]);
814
+ if (!busyV4 && !busyV6) return port;
815
+ }
816
+ return preferred;
817
+ }
818
+
661
819
  async function runWebInterface(
662
820
  flagEnvVars: Record<string, string>,
663
821
  parsedFlagOverrides: Record<string, boolean | string>,
@@ -699,120 +857,118 @@ async function runWebInterface(
699
857
  `<script>window.__VELLUM_CONFIG__=${configJson}${flagOverridesSnippet}</script></head>`,
700
858
  );
701
859
 
702
- const server = Bun.serve({
703
- port: 3000,
704
- hostname: "127.0.0.1",
705
- fetch: async (req) => {
706
- const url = new URL(req.url);
707
- const { pathname } = url;
860
+ const fetchHandler: WebFetchHandler = async (req, server) => {
861
+ const url = new URL(req.url);
862
+ const { pathname } = url;
708
863
 
709
- if (pathname === "/" || pathname === "/assistant") {
710
- return Response.redirect(SPA_BASE, 302);
711
- }
864
+ if (pathname === "/" || pathname === "/assistant") {
865
+ return Response.redirect(SPA_BASE, 302);
866
+ }
712
867
 
713
- // Loopback auth: the platform redirects here after login with
714
- // ?state=...&session_token=... — forward into the SPA.
715
- if (pathname === "/callback") {
716
- return Response.redirect(
717
- `/account/platform-callback${url.search}`,
718
- 302,
719
- );
720
- }
868
+ // Loopback auth: the platform redirects here after login with
869
+ // ?state=...&session_token=... — forward into the SPA, which validates the
870
+ // `state` nonce before registering the token via /__local/platform-session.
871
+ if (pathname === "/callback") {
872
+ return Response.redirect(`/account/platform-callback${url.search}`, 302);
873
+ }
721
874
 
722
- // Expose environment config to the SPA.
723
- if (pathname === "/assistant/__config" || pathname === "/__config") {
724
- return new Response(configJson, {
725
- headers: { "Content-Type": "application/json" },
726
- });
727
- }
875
+ // Expose environment config to the SPA.
876
+ if (pathname === "/assistant/__config" || pathname === "/__config") {
877
+ return new Response(configJson, {
878
+ headers: { "Content-Type": "application/json" },
879
+ });
880
+ }
728
881
 
729
- // __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
730
- const localResponse = await handleLocalEndpoints(req, url, server);
731
- if (localResponse) return localResponse;
732
-
733
- // Reverse-proxy platform API requests.
734
- if (
735
- pathname.startsWith("/v1/") ||
736
- pathname.startsWith("/_allauth/") ||
737
- pathname.startsWith("/accounts/")
738
- ) {
739
- const target = new URL(pathname + url.search, platformUrl);
740
- const headers = new Headers(req.headers);
741
- headers.set("Host", new URL(platformUrl).host);
742
- headers.delete("Origin");
743
- headers.delete("Referer");
744
-
745
- // Forward the session token — the loopback flow stores it in
746
- // the browser cookie jar for localhost, but the platform backend
747
- // expects it on its own domain. Set both the Cookie (for Django
748
- // session middleware / allauth) and X-Session-Token (for DRF
749
- // views that accept header-based auth).
750
- const sessionToken = /sessionid=([^;]+)/.exec(
751
- req.headers.get("Cookie") ?? "",
752
- )?.[1];
753
- if (sessionToken) {
754
- headers.set(
755
- "Cookie",
756
- `sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
757
- );
758
- headers.set("X-Session-Token", sessionToken);
759
- }
882
+ // __local endpoints for local-mode (lockfile, hatch, retire, guardian-token, gateway-proxy).
883
+ const localResponse = await handleLocalEndpoints(req, url, server);
884
+ if (localResponse) return localResponse;
760
885
 
761
- try {
762
- const hasBody = req.method !== "GET" && req.method !== "HEAD";
763
- const body = hasBody ? await req.arrayBuffer() : undefined;
764
- const proxyRes = await loopbackSafeFetch(target.toString(), {
765
- method: req.method,
766
- headers,
767
- body,
768
- redirect: "manual",
769
- });
770
- const resHeaders = new Headers(proxyRes.headers);
771
- resHeaders.delete("transfer-encoding");
772
- return new Response(proxyRes.body, {
773
- status: proxyRes.status,
774
- statusText: proxyRes.statusText,
775
- headers: resHeaders,
776
- });
777
- } catch (err) {
778
- return new Response(
779
- JSON.stringify({ error: `Platform proxy error: ${err}` }),
780
- { status: 502, headers: { "Content-Type": "application/json" } },
781
- );
782
- }
886
+ // Reverse-proxy platform API requests.
887
+ if (
888
+ pathname.startsWith("/v1/") ||
889
+ pathname.startsWith("/_allauth/") ||
890
+ pathname.startsWith("/accounts/")
891
+ ) {
892
+ const target = new URL(pathname + url.search, platformUrl);
893
+ const headers = new Headers(req.headers);
894
+ headers.set("Host", new URL(platformUrl).host);
895
+ headers.delete("Origin");
896
+ headers.delete("Referer");
897
+
898
+ // Authenticate with the loopback session token the SPA registered. The
899
+ // platform expects it both as the Django session cookie and as
900
+ // X-Session-Token (for DRF views that accept header-based auth). Only
901
+ // same-origin SPA traffic gets the credential — never a cross-site caller.
902
+ const sessionToken = isSameOriginRequest(req)
903
+ ? currentPlatformToken()
904
+ : null;
905
+ if (sessionToken) {
906
+ headers.set(
907
+ "Cookie",
908
+ `sessionid=${sessionToken}; __Secure-sessionid=${sessionToken}`,
909
+ );
910
+ headers.set("X-Session-Token", sessionToken);
783
911
  }
784
912
 
785
- if (pathname.startsWith(SPA_BASE)) {
786
- const relPath = pathname.slice(SPA_BASE.length);
787
- if (relPath) {
788
- const filePath = path.join(distDir, relPath);
789
- const file = Bun.file(filePath);
790
- if (await file.exists()) {
791
- return new Response(file);
792
- }
793
- }
794
- return new Response(indexHtml, {
795
- headers: { "Content-Type": "text/html; charset=utf-8" },
913
+ try {
914
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
915
+ const body = hasBody ? await req.arrayBuffer() : undefined;
916
+ const proxyRes = await loopbackSafeFetch(target.toString(), {
917
+ method: req.method,
918
+ headers,
919
+ body,
920
+ redirect: "manual",
796
921
  });
922
+ const resHeaders = new Headers(proxyRes.headers);
923
+ resHeaders.delete("transfer-encoding");
924
+ return new Response(proxyRes.body, {
925
+ status: proxyRes.status,
926
+ statusText: proxyRes.statusText,
927
+ headers: resHeaders,
928
+ });
929
+ } catch (err) {
930
+ return new Response(
931
+ JSON.stringify({ error: `Platform proxy error: ${err}` }),
932
+ { status: 502, headers: { "Content-Type": "application/json" } },
933
+ );
797
934
  }
935
+ }
798
936
 
799
- // SPA fallback for /account/* routes (login, callback, etc.)
800
- if (pathname.startsWith("/account/")) {
801
- return new Response(indexHtml, {
802
- headers: { "Content-Type": "text/html; charset=utf-8" },
803
- });
937
+ if (pathname.startsWith(SPA_BASE)) {
938
+ const relPath = pathname.slice(SPA_BASE.length);
939
+ if (relPath) {
940
+ const filePath = path.join(distDir, relPath);
941
+ const file = Bun.file(filePath);
942
+ if (await file.exists()) {
943
+ return new Response(file);
944
+ }
804
945
  }
946
+ return new Response(indexHtml, {
947
+ headers: { "Content-Type": "text/html; charset=utf-8" },
948
+ });
949
+ }
805
950
 
806
- return new Response("Not Found", { status: 404 });
807
- },
808
- });
951
+ // SPA fallback for /account/* routes (login, callback, etc.)
952
+ if (pathname.startsWith("/account/")) {
953
+ return new Response(indexHtml, {
954
+ headers: { "Content-Type": "text/html; charset=utf-8" },
955
+ });
956
+ }
809
957
 
810
- console.log(
811
- `Vellum web interface: http://${server.hostname}:${server.port}${SPA_BASE}`,
812
- );
958
+ return new Response("Not Found", { status: 404 });
959
+ };
960
+
961
+ const { port, servers } = serveLoopback(3000, fetchHandler);
962
+ if (port !== 3000) {
963
+ console.log(`Port 3000 in use; using ${port}.`);
964
+ }
965
+ // Advertise `localhost` (not `127.0.0.1`) so the app origin matches the host
966
+ // the platform hardcodes in its loopback callback. We bind both loopback
967
+ // families above so `localhost` reaches us whichever one it resolves to.
968
+ console.log(`Vellum web interface: http://localhost:${port}${SPA_BASE}`);
813
969
 
814
970
  const shutdown = (): void => {
815
- server.stop();
971
+ for (const server of servers) server.stop();
816
972
  process.exit(0);
817
973
  };
818
974
  process.on("SIGINT", shutdown);
@@ -834,6 +990,14 @@ async function runViteDevServer(
834
990
  viteFlagVars[`VITE_${envName}`] = value;
835
991
  }
836
992
 
993
+ // Auto-pick a free port (Vite uses strictPort) so a running `vel up` stack
994
+ // on :3000 doesn't wedge dev. The loopback callback port follows
995
+ // window.location.port, so a non-3000 port propagates automatically.
996
+ const port = await findFreeDualLoopbackPort(3000);
997
+ if (port !== 3000) {
998
+ console.log(`Port 3000 in use; using ${port}.`);
999
+ }
1000
+
837
1001
  const child = spawn("bun", ["run", "dev"], {
838
1002
  cwd: webSourceDir,
839
1003
  stdio: "inherit",
@@ -846,7 +1010,7 @@ async function runViteDevServer(
846
1010
  API_PROXY_TARGET: platformUrl,
847
1011
  VELLUM_WEB_URL: getWebUrl(),
848
1012
  VELLUM_PLATFORM_URL: platformUrl,
849
- PORT: "3000",
1013
+ PORT: String(port),
850
1014
  },
851
1015
  });
852
1016
 
@@ -92,7 +92,7 @@ function printHelp(): void {
92
92
  " $ vellum flags # list flags for active assistant",
93
93
  );
94
94
  console.log(
95
- " $ vellum flags get query-complexity-routing # inspect one flag",
95
+ " $ vellum flags get voice-mode # inspect one flag",
96
96
  );
97
97
  console.log(
98
98
  " $ vellum flags set voice-mode true # enable a flag",