@vellumai/cli 0.10.11-dev.202607220140.207f77d → 0.10.11-dev.202607220509.38c4eff

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.11-dev.202607220140.207f77d",
3
+ "version": "0.10.11-dev.202607220509.38c4eff",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,6 +34,7 @@
34
34
  "chalk": "5.6.2",
35
35
  "ink": "6.8.0",
36
36
  "nanoid": "5.1.7",
37
+ "qrcode-terminal": "0.12.0",
37
38
  "react": "19.2.4"
38
39
  },
39
40
  "bundledDependencies": [
@@ -42,6 +43,7 @@
42
43
  ],
43
44
  "devDependencies": {
44
45
  "@types/bun": "1.3.11",
46
+ "@types/qrcode-terminal": "0.12.2",
45
47
  "@types/react": "19.2.14",
46
48
  "eslint": "10.1.0",
47
49
  "knip": "5.88.1",
@@ -467,4 +467,337 @@ describe("pair command", () => {
467
467
  expiresAt: "2026-06-04T00:10:00.000Z",
468
468
  });
469
469
  });
470
+
471
+ test("--qr mints a challenge, auto-approves it, and emits a device-code link", async () => {
472
+ const calls: Array<[string, RequestInit | undefined]> = [];
473
+ const origFetch = globalThis.fetch;
474
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
475
+ calls.push([url, init]);
476
+ if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
477
+ return new Response(
478
+ JSON.stringify({
479
+ flags: [{ key: "web-remote-ingress", enabled: true }],
480
+ }),
481
+ { status: 200, headers: { "content-type": "application/json" } },
482
+ );
483
+ }
484
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
485
+ return new Response(
486
+ JSON.stringify({
487
+ deviceCode: "device-code",
488
+ userCode: "ABCD-EFGH",
489
+ verificationUri: "https://pair.example.ts.net/assistant/pair",
490
+ expiresAt: "2026-06-04T00:10:00.000Z",
491
+ expiresInSeconds: 600,
492
+ }),
493
+ { status: 200, headers: { "content-type": "application/json" } },
494
+ );
495
+ }
496
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
497
+ return new Response(
498
+ JSON.stringify({
499
+ status: "approved",
500
+ verificationUri: "https://pair.example.ts.net/assistant/pair",
501
+ expiresAt: "2026-06-04T00:10:00.000Z",
502
+ }),
503
+ { status: 200, headers: { "content-type": "application/json" } },
504
+ );
505
+ }
506
+ return new Response("not found", { status: 404 });
507
+ }) as unknown as typeof fetch;
508
+
509
+ const logs: string[] = [];
510
+ const logSpy = spyOn(console, "log").mockImplementation(
511
+ (...a: unknown[]) => {
512
+ logs.push(a.join(" "));
513
+ },
514
+ );
515
+
516
+ process.argv = [
517
+ "bun",
518
+ "vellum",
519
+ "pair",
520
+ "--qr",
521
+ "--url",
522
+ "https://pair.example.ts.net",
523
+ "--json",
524
+ ];
525
+ try {
526
+ await pair();
527
+ } finally {
528
+ logSpy.mockRestore();
529
+ globalThis.fetch = origFetch;
530
+ }
531
+
532
+ // Flag check → create challenge → approve it, all over loopback. The
533
+ // approval is the whole point: running the CLI on the host IS the approval,
534
+ // so the scan alone completes pairing.
535
+ expect(calls.map((c) => c[0])).toEqual([
536
+ `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`,
537
+ `${LOCAL_URL}/v1/remote-web/pairing-challenge`,
538
+ `${LOCAL_URL}/v1/remote-web/pairing-verification`,
539
+ ]);
540
+ expect(JSON.parse(calls[1][1]?.body as string)).toEqual({
541
+ publicBaseUrl: "https://pair.example.ts.net",
542
+ });
543
+ expect(JSON.parse(calls[2][1]?.body as string)).toEqual({
544
+ userCode: "ABCD-EFGH",
545
+ });
546
+
547
+ const out = JSON.parse(logs.join("\n"));
548
+ expect(out).toEqual({
549
+ pairUrl:
550
+ "https://pair.example.ts.net/assistant/pair#device_code=device-code",
551
+ deviceCode: "device-code",
552
+ expiresAt: "2026-06-04T00:10:00.000Z",
553
+ expiresInSeconds: 600,
554
+ });
555
+ // The device code rides the fragment only — never the path or query.
556
+ const parsed = new URL(out.pairUrl);
557
+ expect(parsed.search).toBe("");
558
+ expect(parsed.hash).toBe("#device_code=device-code");
559
+ });
560
+
561
+ test("--qr renders a QR and prints the fallback URL and expiry", async () => {
562
+ const origFetch = globalThis.fetch;
563
+ globalThis.fetch = (async (url: string) => {
564
+ if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
565
+ return new Response(
566
+ JSON.stringify({
567
+ flags: [{ key: "web-remote-ingress", enabled: true }],
568
+ }),
569
+ { status: 200, headers: { "content-type": "application/json" } },
570
+ );
571
+ }
572
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
573
+ return new Response(
574
+ JSON.stringify({
575
+ deviceCode: "device-code",
576
+ userCode: "ABCD-EFGH",
577
+ verificationUri: "https://pair.example.ts.net/assistant/pair",
578
+ expiresAt: "2026-06-04T00:10:00.000Z",
579
+ expiresInSeconds: 600,
580
+ }),
581
+ { status: 200, headers: { "content-type": "application/json" } },
582
+ );
583
+ }
584
+ return new Response(
585
+ JSON.stringify({
586
+ status: "approved",
587
+ verificationUri: "https://pair.example.ts.net/assistant/pair",
588
+ expiresAt: "2026-06-04T00:10:00.000Z",
589
+ }),
590
+ { status: 200, headers: { "content-type": "application/json" } },
591
+ );
592
+ }) as unknown as typeof fetch;
593
+
594
+ const logs: string[] = [];
595
+ const logSpy = spyOn(console, "log").mockImplementation(
596
+ (...a: unknown[]) => {
597
+ logs.push(a.join(" "));
598
+ },
599
+ );
600
+
601
+ process.argv = [
602
+ "bun",
603
+ "vellum",
604
+ "pair",
605
+ "--qr",
606
+ "--url",
607
+ "https://pair.example.ts.net",
608
+ ];
609
+ try {
610
+ await pair();
611
+ } finally {
612
+ logSpy.mockRestore();
613
+ globalThis.fetch = origFetch;
614
+ }
615
+
616
+ const output = logs.join("\n");
617
+ expect(output).toContain(
618
+ "https://pair.example.ts.net/assistant/pair#device_code=device-code",
619
+ );
620
+ expect(output).toContain("Expires: 2026-06-04T00:10:00.000Z");
621
+ });
622
+
623
+ test("--qr refuses a non-https --url without minting", async () => {
624
+ let fetchCalled = false;
625
+ const origFetch = globalThis.fetch;
626
+ globalThis.fetch = (async () => {
627
+ fetchCalled = true;
628
+ return new Response("{}", { status: 200 });
629
+ }) as unknown as typeof fetch;
630
+ const errors: string[] = [];
631
+ const errSpy = spyOn(console, "error").mockImplementation(
632
+ (...a: unknown[]) => {
633
+ errors.push(a.join(" "));
634
+ },
635
+ );
636
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
637
+ code?: number,
638
+ ) => {
639
+ throw new Error(`exit:${code}`);
640
+ }) as never);
641
+
642
+ process.argv = [
643
+ "bun",
644
+ "vellum",
645
+ "pair",
646
+ "--qr",
647
+ "--url",
648
+ "http://pair.example.com",
649
+ ];
650
+ let exited = false;
651
+ try {
652
+ await pair();
653
+ } catch (e) {
654
+ exited = (e as Error).message === "exit:1";
655
+ } finally {
656
+ errSpy.mockRestore();
657
+ exitSpy.mockRestore();
658
+ globalThis.fetch = origFetch;
659
+ }
660
+
661
+ // Validation is local and fails fast — no challenge minted for a dead link.
662
+ expect(exited).toBe(true);
663
+ expect(errors.join("\n")).toContain("https");
664
+ expect(fetchCalled).toBe(false);
665
+ });
666
+
667
+ test("--qr refuses a loopback --url without minting", async () => {
668
+ let fetchCalled = false;
669
+ const origFetch = globalThis.fetch;
670
+ globalThis.fetch = (async () => {
671
+ fetchCalled = true;
672
+ return new Response("{}", { status: 200 });
673
+ }) as unknown as typeof fetch;
674
+ const errors: string[] = [];
675
+ const errSpy = spyOn(console, "error").mockImplementation(
676
+ (...a: unknown[]) => {
677
+ errors.push(a.join(" "));
678
+ },
679
+ );
680
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
681
+ code?: number,
682
+ ) => {
683
+ throw new Error(`exit:${code}`);
684
+ }) as never);
685
+
686
+ process.argv = [
687
+ "bun",
688
+ "vellum",
689
+ "pair",
690
+ "--qr",
691
+ "--url",
692
+ "http://127.0.0.1:7830",
693
+ ];
694
+ let exited = false;
695
+ try {
696
+ await pair();
697
+ } catch (e) {
698
+ exited = (e as Error).message === "exit:1";
699
+ } finally {
700
+ errSpy.mockRestore();
701
+ exitSpy.mockRestore();
702
+ globalThis.fetch = origFetch;
703
+ }
704
+
705
+ expect(exited).toBe(true);
706
+ expect(errors.join("\n")).toContain("loopback");
707
+ expect(fetchCalled).toBe(false);
708
+ });
709
+
710
+ test("--qr refuses an unparseable --url with an accurate error, not a non-https mislabel", async () => {
711
+ let fetchCalled = false;
712
+ const origFetch = globalThis.fetch;
713
+ globalThis.fetch = (async () => {
714
+ fetchCalled = true;
715
+ return new Response("{}", { status: 200 });
716
+ }) as unknown as typeof fetch;
717
+ const errors: string[] = [];
718
+ const errSpy = spyOn(console, "error").mockImplementation(
719
+ (...a: unknown[]) => {
720
+ errors.push(a.join(" "));
721
+ },
722
+ );
723
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
724
+ code?: number,
725
+ ) => {
726
+ throw new Error(`exit:${code}`);
727
+ }) as never);
728
+
729
+ process.argv = ["bun", "vellum", "pair", "--qr", "--url", "not-a-url"];
730
+ let exited = false;
731
+ try {
732
+ await pair();
733
+ } catch (e) {
734
+ exited = (e as Error).message === "exit:1";
735
+ } finally {
736
+ errSpy.mockRestore();
737
+ exitSpy.mockRestore();
738
+ globalThis.fetch = origFetch;
739
+ }
740
+
741
+ // Unparseable input reports its own reason — not the loopback/non-https
742
+ // messages the reason-blind version reconstructed.
743
+ expect(exited).toBe(true);
744
+ const joined = errors.join("\n");
745
+ expect(joined).toContain("isn't a valid URL");
746
+ expect(joined).not.toContain("is not https");
747
+ expect(joined).not.toContain("loopback");
748
+ expect(fetchCalled).toBe(false);
749
+ });
750
+
751
+ test("--qr refuses when the web remote ingress feature flag is off", async () => {
752
+ const calls: Array<[string, RequestInit | undefined]> = [];
753
+ const origFetch = globalThis.fetch;
754
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
755
+ calls.push([url, init]);
756
+ return new Response(
757
+ JSON.stringify({
758
+ flags: [{ key: "web-remote-ingress", enabled: false }],
759
+ }),
760
+ { status: 200, headers: { "content-type": "application/json" } },
761
+ );
762
+ }) as unknown as typeof fetch;
763
+
764
+ const errors: string[] = [];
765
+ const errSpy = spyOn(console, "error").mockImplementation(
766
+ (...a: unknown[]) => {
767
+ errors.push(a.join(" "));
768
+ },
769
+ );
770
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
771
+ code?: number,
772
+ ) => {
773
+ throw new Error(`exit:${code}`);
774
+ }) as never);
775
+
776
+ process.argv = [
777
+ "bun",
778
+ "vellum",
779
+ "pair",
780
+ "--qr",
781
+ "--url",
782
+ "https://pair.example.ts.net",
783
+ ];
784
+ let exited = false;
785
+ try {
786
+ await pair();
787
+ } catch (e) {
788
+ exited = (e as Error).message === "exit:1";
789
+ } finally {
790
+ errSpy.mockRestore();
791
+ exitSpy.mockRestore();
792
+ globalThis.fetch = origFetch;
793
+ }
794
+
795
+ // Only the flag check runs; no challenge is minted when the flag is off.
796
+ expect(exited).toBe(true);
797
+ expect(errors.join("\n")).toContain("web-remote-ingress");
798
+ expect(calls).toHaveLength(1);
799
+ expect(calls[0][0]).toBe(
800
+ `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`,
801
+ );
802
+ });
470
803
  });
@@ -11,6 +11,9 @@
11
11
  */
12
12
 
13
13
  import { nanoid } from "nanoid";
14
+ // Call `qrcodeTerminal.generate` as a method — the library reads its default
15
+ // error-correction level off `this`, so a destructured import renders nothing.
16
+ import qrcodeTerminal from "qrcode-terminal";
14
17
 
15
18
  import { extractFlag } from "../lib/arg-utils.js";
16
19
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
@@ -31,15 +34,10 @@ import {
31
34
  WEB_REMOTE_INGRESS_FLAG,
32
35
  } from "../lib/feature-flags.js";
33
36
  import { getLocalLanIPv4 } from "../lib/local.js";
34
- import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
37
+ import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
35
38
 
36
- function isLoopbackHost(url: string): boolean {
37
- try {
38
- const host = new URL(url).hostname.toLowerCase();
39
- return host === "localhost" || host === "::1" || host.startsWith("127.");
40
- } catch {
41
- return false;
42
- }
39
+ function assistantDisplayName(entry: AssistantEntry): string {
40
+ return entry.name || entry.assistantName || entry.assistantId;
43
41
  }
44
42
 
45
43
  function printUsage(): void {
@@ -58,7 +56,13 @@ OPTIONS:
58
56
  --web Create a browser pairing URL for remote web access
59
57
  --web-approve <code>
60
58
  Approve a browser pairing code shown by /assistant/pair
61
- --json Output the raw bundle as JSON
59
+ --qr Render a QR code that pairs a device in one scan. Mints a
60
+ remote-web pairing challenge and approves it locally, so the
61
+ scan alone completes pairing. Needs a public https URL
62
+ (--url, else the assistant's runtime URL); refuses
63
+ loopback or non-https URLs.
64
+ --json Output the result as JSON. With --qr: {pairUrl, deviceCode,
65
+ expiresAt, expiresInSeconds}
62
66
 
63
67
  EXAMPLES:
64
68
  vellum pair
@@ -66,7 +70,8 @@ EXAMPLES:
66
70
  vellum pair --url https://abc123.ngrok.app
67
71
  vellum pair --web --url https://abc123.ngrok.app
68
72
  vellum pair --web-approve ABCD-EFGH
69
- vellum pair --json
73
+ vellum pair --qr --url https://your-assistant.ts.net
74
+ vellum pair --qr --json
70
75
  `);
71
76
  }
72
77
 
@@ -119,6 +124,107 @@ function buildRemoteWebPairingUrl(
119
124
  return url.toString();
120
125
  }
121
126
 
127
+ type QrPublicBaseUrlFailureReason = "unparseable" | "loopback" | "non-https";
128
+
129
+ type QrPublicBaseUrlResult =
130
+ | { ok: true; url: string }
131
+ | { ok: false; reason: QrPublicBaseUrlFailureReason };
132
+
133
+ /**
134
+ * Normalize the advertised URL to the public https origin a scanning phone can
135
+ * open, or report why it isn't internet-reachable. Stricter than the copy-paste
136
+ * bundle path's loopback guard: a QR that encodes a loopback or plain-http link
137
+ * is unusable from another device, so both are refused. The failure reason is
138
+ * returned so the caller can emit an accurate message per case.
139
+ */
140
+ function resolveQrPublicBaseUrl(advertisedUrl: string): QrPublicBaseUrlResult {
141
+ let normalized: string;
142
+ try {
143
+ normalized = normalizePublicBaseUrl(advertisedUrl);
144
+ } catch {
145
+ return { ok: false, reason: "unparseable" };
146
+ }
147
+ if (isLoopbackUrl(normalized)) {
148
+ return { ok: false, reason: "loopback" };
149
+ }
150
+ if (new URL(normalized).protocol !== "https:") {
151
+ return { ok: false, reason: "non-https" };
152
+ }
153
+ return { ok: true, url: normalized };
154
+ }
155
+
156
+ /**
157
+ * POST a JSON body to a loopback gateway route, exiting with a clear message
158
+ * when the gateway is unreachable or answers non-2xx. Every pairing subcommand
159
+ * talks to the gateway this way, so the reachability + HTTP-error handling has
160
+ * a single home.
161
+ */
162
+ async function gatewayPostOrExit(
163
+ gatewayUrl: string,
164
+ path: string,
165
+ body: unknown,
166
+ headers?: Record<string, string>,
167
+ ): Promise<Response> {
168
+ let response: Response;
169
+ try {
170
+ response = await loopbackSafeFetch(`${gatewayUrl}${path}`, {
171
+ method: "POST",
172
+ headers: { "Content-Type": "application/json", ...headers },
173
+ body: JSON.stringify(body),
174
+ });
175
+ } catch (err) {
176
+ console.error(
177
+ `Error: could not reach the gateway at ${gatewayUrl} ` +
178
+ `(${err instanceof Error ? err.message : String(err)}).`,
179
+ );
180
+ console.error("Is the assistant running? Try `vellum wake`.");
181
+ process.exit(1);
182
+ }
183
+
184
+ if (!response.ok) {
185
+ const errorBody = await response.text().catch(() => "");
186
+ console.error(
187
+ `Error: HTTP ${response.status}: ${errorBody || response.statusText}`,
188
+ );
189
+ process.exit(1);
190
+ }
191
+
192
+ return response;
193
+ }
194
+
195
+ /**
196
+ * Create a remote-web pairing challenge (RFC 8628 device-code flow). Shared by
197
+ * `--web` and `--qr`, which differ only in how they present the same result.
198
+ */
199
+ async function createRemoteWebPairingChallenge(
200
+ gatewayUrl: string,
201
+ publicBaseUrl: string,
202
+ ): Promise<RemoteWebPairingChallengeResponse> {
203
+ const response = await gatewayPostOrExit(
204
+ gatewayUrl,
205
+ "/v1/remote-web/pairing-challenge",
206
+ { publicBaseUrl },
207
+ );
208
+ return (await response.json()) as RemoteWebPairingChallengeResponse;
209
+ }
210
+
211
+ /**
212
+ * Approve a pending pairing challenge by its user code — the local-presence
213
+ * proof for the device-code flow. Shared by `--web-approve` and `--qr` (which
214
+ * approves the challenge it just minted so one scan completes pairing).
215
+ */
216
+ async function approveRemoteWebPairing(
217
+ gatewayUrl: string,
218
+ userCode: string,
219
+ ): Promise<RemoteWebPairingApprovalResponse> {
220
+ const response = await gatewayPostOrExit(
221
+ gatewayUrl,
222
+ "/v1/remote-web/pairing-verification",
223
+ { userCode },
224
+ );
225
+ return (await response.json()) as RemoteWebPairingApprovalResponse;
226
+ }
227
+
122
228
  async function assertWebRemoteIngressEnabled(
123
229
  assistantId: string,
124
230
  runtimeUrl: string,
@@ -158,7 +264,10 @@ export async function pair(): Promise<void> {
158
264
  const jsonOutput = rawArgs.includes("--json");
159
265
  const webPairing = rawArgs.includes("--web");
160
266
  const webApproval = rawArgs.includes("--web-approve");
161
- let args = rawArgs.filter((a) => a !== "--json" && a !== "--web");
267
+ const qrPairing = rawArgs.includes("--qr");
268
+ let args = rawArgs.filter(
269
+ (a) => a !== "--json" && a !== "--web" && a !== "--qr",
270
+ );
162
271
 
163
272
  const [label, afterLabel] = extractFlag(args, "--label");
164
273
  const [webApproveCode, afterWebApprove] = extractFlag(
@@ -176,6 +285,10 @@ export async function pair(): Promise<void> {
176
285
  console.error("Error: --web-approve requires a pairing code.");
177
286
  process.exit(1);
178
287
  }
288
+ if (qrPairing && (webPairing || webApproveCode)) {
289
+ console.error("Error: --qr can't be combined with --web or --web-approve.");
290
+ process.exit(1);
291
+ }
179
292
 
180
293
  // Resolve the target. An explicit argument is matched by display name OR id
181
294
  // (with the standard ambiguity error); no argument falls back to the active
@@ -216,7 +329,12 @@ export async function pair(): Promise<void> {
216
329
  // so without an explicit --url the bundle would point the other machine at
217
330
  // its own localhost. Refuse to advertise a loopback URL unless the user
218
331
  // explicitly passed one. (An explicit --url is trusted as-is.)
219
- if (!urlOverride && !webApproveCode && isLoopbackHost(advertisedUrl)) {
332
+ if (
333
+ !urlOverride &&
334
+ !webApproveCode &&
335
+ !qrPairing &&
336
+ isLoopbackUrl(advertisedUrl)
337
+ ) {
220
338
  const lan = getLocalLanIPv4();
221
339
  // Use THIS assistant's gateway port (not the global default) — second
222
340
  // local instances listen on a different port.
@@ -242,34 +360,7 @@ export async function pair(): Promise<void> {
242
360
  if (webApproveCode) {
243
361
  await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
244
362
 
245
- let response: Response;
246
- try {
247
- response = await loopbackSafeFetch(
248
- `${mintUrl}/v1/remote-web/pairing-verification`,
249
- {
250
- method: "POST",
251
- headers: { "Content-Type": "application/json" },
252
- body: JSON.stringify({ userCode: webApproveCode }),
253
- },
254
- );
255
- } catch (err) {
256
- console.error(
257
- `Error: could not reach the gateway at ${mintUrl} ` +
258
- `(${err instanceof Error ? err.message : String(err)}).`,
259
- );
260
- console.error("Is the assistant running? Try `vellum wake`.");
261
- process.exit(1);
262
- }
263
-
264
- if (!response.ok) {
265
- const body = await response.text().catch(() => "");
266
- console.error(
267
- `Error: HTTP ${response.status}: ${body || response.statusText}`,
268
- );
269
- process.exit(1);
270
- }
271
-
272
- const result = (await response.json()) as RemoteWebPairingApprovalResponse;
363
+ const result = await approveRemoteWebPairing(mintUrl, webApproveCode);
273
364
  if (jsonOutput) {
274
365
  console.log(JSON.stringify(result, null, 2));
275
366
  return;
@@ -290,35 +381,10 @@ export async function pair(): Promise<void> {
290
381
  process.exit(1);
291
382
  }
292
383
 
293
- let response: Response;
294
- try {
295
- response = await loopbackSafeFetch(
296
- `${mintUrl}/v1/remote-web/pairing-challenge`,
297
- {
298
- method: "POST",
299
- headers: { "Content-Type": "application/json" },
300
- body: JSON.stringify({ publicBaseUrl }),
301
- },
302
- );
303
- } catch (err) {
304
- console.error(
305
- `Error: could not reach the gateway at ${mintUrl} ` +
306
- `(${err instanceof Error ? err.message : String(err)}).`,
307
- );
308
- console.error("Is the assistant running? Try `vellum wake`.");
309
- process.exit(1);
310
- }
311
-
312
- if (!response.ok) {
313
- const body = await response.text().catch(() => "");
314
- console.error(
315
- `Error: HTTP ${response.status}: ${body || response.statusText}`,
316
- );
317
- process.exit(1);
318
- }
319
-
320
- const challenge =
321
- (await response.json()) as RemoteWebPairingChallengeResponse;
384
+ const challenge = await createRemoteWebPairingChallenge(
385
+ mintUrl,
386
+ publicBaseUrl,
387
+ );
322
388
  const pairUrl = buildRemoteWebPairingUrl(challenge);
323
389
 
324
390
  if (jsonOutput) {
@@ -338,7 +404,7 @@ export async function pair(): Promise<void> {
338
404
  return;
339
405
  }
340
406
 
341
- const displayName = entry.name || entry.assistantName || entry.assistantId;
407
+ const displayName = assistantDisplayName(entry);
342
408
  console.log(`Created remote web pairing for ${displayName}.`);
343
409
  console.log("");
344
410
  console.log("Open this URL in the browser:");
@@ -359,36 +425,78 @@ export async function pair(): Promise<void> {
359
425
  return;
360
426
  }
361
427
 
362
- // Fresh per-pairing device identity — each `vellum pair` is independently
363
- // revocable.
364
- const deviceId = nanoid();
428
+ if (qrPairing) {
429
+ // Validate the public URL before any network call — a QR that encodes a
430
+ // loopback or plain-http link is unscannable from another device.
431
+ const qrResult = resolveQrPublicBaseUrl(advertisedUrl);
432
+ if (!qrResult.ok) {
433
+ const detailByReason: Record<QrPublicBaseUrlFailureReason, string> = {
434
+ unparseable: `${advertisedUrl} isn't a valid URL`,
435
+ loopback: `${advertisedUrl} is a loopback address`,
436
+ "non-https": `${advertisedUrl} is not https`,
437
+ };
438
+ console.error(
439
+ "Error: --qr needs a public https URL the phone can open — " +
440
+ `${detailByReason[qrResult.reason]}.`,
441
+ );
442
+ console.error(
443
+ "Re-run with your assistant's public URL, e.g.:\n" +
444
+ " vellum pair --qr --url https://your-assistant.ts.net",
445
+ );
446
+ process.exit(1);
447
+ }
448
+ const qrBaseUrl = qrResult.url;
365
449
 
366
- let response: Response;
367
- try {
368
- response = await loopbackSafeFetch(`${mintUrl}/v1/pair`, {
369
- method: "POST",
370
- headers: {
371
- "Content-Type": "application/json",
372
- ...getClientRegistrationHeaders(CLI_INTERFACE_ID),
373
- },
374
- body: JSON.stringify({ deviceId, platform: "cli" }),
450
+ await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
451
+
452
+ // Mint a challenge and immediately approve it: running this CLI on the host
453
+ // IS the local-presence proof, so the scanning device completes pairing in
454
+ // one step. Reuses the `--web` + `--web-approve` code paths.
455
+ const challenge = await createRemoteWebPairingChallenge(mintUrl, qrBaseUrl);
456
+ await approveRemoteWebPairing(mintUrl, challenge.userCode);
457
+ const pairUrl = buildRemoteWebPairingUrl(challenge);
458
+
459
+ if (jsonOutput) {
460
+ console.log(
461
+ JSON.stringify(
462
+ {
463
+ pairUrl,
464
+ deviceCode: challenge.deviceCode,
465
+ expiresAt: challenge.expiresAt,
466
+ expiresInSeconds: challenge.expiresInSeconds,
467
+ },
468
+ null,
469
+ 2,
470
+ ),
471
+ );
472
+ return;
473
+ }
474
+
475
+ const displayName = assistantDisplayName(entry);
476
+ console.log(`Scan to pair a device with ${displayName}:`);
477
+ console.log("");
478
+ qrcodeTerminal.generate(pairUrl, { small: true }, (qr) => {
479
+ console.log(qr);
375
480
  });
376
- } catch (err) {
377
- console.error(
378
- `Error: could not reach the gateway at ${mintUrl} ` +
379
- `(${err instanceof Error ? err.message : String(err)}).`,
380
- );
381
- console.error("Is the assistant running? Try `vellum wake`.");
382
- process.exit(1);
481
+ console.log("");
482
+ console.log("Or open this URL on the device:");
483
+ console.log("");
484
+ console.log(` ${pairUrl}`);
485
+ console.log("");
486
+ console.log(`Expires: ${challenge.expiresAt}`);
487
+ return;
383
488
  }
384
489
 
385
- if (!response.ok) {
386
- const body = await response.text().catch(() => "");
387
- console.error(
388
- `Error: HTTP ${response.status}: ${body || response.statusText}`,
389
- );
390
- process.exit(1);
391
- }
490
+ // Fresh per-pairing device identity — each `vellum pair` is independently
491
+ // revocable.
492
+ const deviceId = nanoid();
493
+
494
+ const response = await gatewayPostOrExit(
495
+ mintUrl,
496
+ "/v1/pair",
497
+ { deviceId, platform: "cli" },
498
+ getClientRegistrationHeaders(CLI_INTERFACE_ID),
499
+ );
392
500
 
393
501
  const result = (await response.json()) as PairResponse;
394
502
 
@@ -419,7 +527,7 @@ export async function pair(): Promise<void> {
419
527
  return;
420
528
  }
421
529
 
422
- const displayName = entry.name || entry.assistantName || entry.assistantId;
530
+ const displayName = assistantDisplayName(entry);
423
531
  console.log(`Paired ${label ? `"${label}" ` : ""}with ${displayName}.`);
424
532
  console.log("");
425
533
  console.log(` Gateway: ${advertisedUrl}`);
@@ -6,7 +6,7 @@
6
6
  * connection per request; remote hosts are unaffected.
7
7
  */
8
8
 
9
- function isLoopbackUrl(url: string): boolean {
9
+ export function isLoopbackUrl(url: string): boolean {
10
10
  try {
11
11
  // WHATWG URL canonicalizes hostnames, so IPv6 loopback is always "[::1]".
12
12
  const h = new URL(url).hostname;