@vellumai/cli 0.9.1-staging.1 → 0.10.0-staging.2

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.9.1-staging.1",
3
+ "version": "0.10.0-staging.2",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -127,8 +127,6 @@ describe("buildIngressNginxConfig", () => {
127
127
  "location = /v1/pair/ { return 404; }",
128
128
  "location = /v1/pair/web-init { return 404; }",
129
129
  "location = /v1/pair/web-init/ { return 404; }",
130
- "location = /v1/remote-web/pairing-challenge { return 404; }",
131
- "location = /v1/remote-web/pairing-challenge/ { return 404; }",
132
130
  "location = /v1/devices { return 404; }",
133
131
  "location = /v1/devices/ { return 404; }",
134
132
  "location = /v1/devices/revoke { return 404; }",
@@ -268,4 +268,203 @@ describe("pair command", () => {
268
268
  const out = JSON.parse(logs.join("\n"));
269
269
  expect(out.gatewayUrl).toBe(OVERRIDE);
270
270
  });
271
+
272
+ test("--web creates a browser pairing URL without printing tokens", async () => {
273
+ const calls: Array<[string, RequestInit | undefined]> = [];
274
+ const origFetch = globalThis.fetch;
275
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
276
+ calls.push([url, init]);
277
+ if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
278
+ return new Response(
279
+ JSON.stringify({
280
+ flags: [{ key: "web-remote-ingress", enabled: true }],
281
+ }),
282
+ { status: 200, headers: { "content-type": "application/json" } },
283
+ );
284
+ }
285
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-challenge`) {
286
+ return new Response(
287
+ JSON.stringify({
288
+ deviceCode: "device-code",
289
+ userCode: "ABCD-EFGH",
290
+ verificationUri:
291
+ "https://abc123.ngrok.app/assistant-123/assistant/pair",
292
+ expiresAt: "2026-06-04T00:10:00.000Z",
293
+ expiresInSeconds: 600,
294
+ intervalSeconds: 5,
295
+ }),
296
+ { status: 200, headers: { "content-type": "application/json" } },
297
+ );
298
+ }
299
+ return new Response("not found", { status: 404 });
300
+ }) as unknown as typeof fetch;
301
+
302
+ const logs: string[] = [];
303
+ const logSpy = spyOn(console, "log").mockImplementation(
304
+ (...a: unknown[]) => {
305
+ logs.push(a.join(" "));
306
+ },
307
+ );
308
+
309
+ process.argv = [
310
+ "bun",
311
+ "vellum",
312
+ "pair",
313
+ "--web",
314
+ "--url",
315
+ "https://abc123.ngrok.app/assistant-123/assistant/",
316
+ "--json",
317
+ ];
318
+ try {
319
+ await pair();
320
+ } finally {
321
+ logSpy.mockRestore();
322
+ globalThis.fetch = origFetch;
323
+ }
324
+
325
+ expect(calls).toHaveLength(2);
326
+ expect(calls[1][0]).toBe(`${LOCAL_URL}/v1/remote-web/pairing-challenge`);
327
+ expect(JSON.parse(calls[1][1]?.body as string)).toEqual({
328
+ publicBaseUrl: "https://abc123.ngrok.app/assistant-123",
329
+ });
330
+
331
+ const out = JSON.parse(logs.join("\n"));
332
+ expect(out).toEqual({
333
+ pairUrl:
334
+ "https://abc123.ngrok.app/assistant-123/assistant/pair#device_code=device-code",
335
+ userCode: "ABCD-EFGH",
336
+ verificationUri: "https://abc123.ngrok.app/assistant-123/assistant/pair",
337
+ expiresAt: "2026-06-04T00:10:00.000Z",
338
+ expiresInSeconds: 600,
339
+ });
340
+ expect(logs.join("\n")).not.toContain("access");
341
+ expect(logs.join("\n")).not.toContain("refresh");
342
+ });
343
+
344
+ test("--web refuses when the web remote ingress feature flag is off", async () => {
345
+ const calls: Array<[string, RequestInit | undefined]> = [];
346
+ const origFetch = globalThis.fetch;
347
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
348
+ calls.push([url, init]);
349
+ return new Response(
350
+ JSON.stringify({
351
+ flags: [{ key: "web-remote-ingress", enabled: false }],
352
+ }),
353
+ { status: 200, headers: { "content-type": "application/json" } },
354
+ );
355
+ }) as unknown as typeof fetch;
356
+
357
+ const errors: string[] = [];
358
+ const errSpy = spyOn(console, "error").mockImplementation(
359
+ (...a: unknown[]) => {
360
+ errors.push(a.join(" "));
361
+ },
362
+ );
363
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
364
+ code?: number,
365
+ ) => {
366
+ throw new Error(`exit:${code}`);
367
+ }) as never);
368
+
369
+ process.argv = [
370
+ "bun",
371
+ "vellum",
372
+ "pair",
373
+ "--web",
374
+ "--url",
375
+ "https://abc123.ngrok.app",
376
+ ];
377
+ let exited = false;
378
+ try {
379
+ await pair();
380
+ } catch (e) {
381
+ exited = (e as Error).message === "exit:1";
382
+ } finally {
383
+ errSpy.mockRestore();
384
+ exitSpy.mockRestore();
385
+ globalThis.fetch = origFetch;
386
+ }
387
+
388
+ expect(exited).toBe(true);
389
+ expect(errors.join("\n")).toContain("web-remote-ingress");
390
+ expect(calls).toHaveLength(1);
391
+ expect(calls[0][0]).toBe(
392
+ `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`,
393
+ );
394
+ });
395
+
396
+ test("--web-approve approves a browser pairing code over loopback", async () => {
397
+ writeFileSync(
398
+ join(testDir, ".vellum.lock.json"),
399
+ JSON.stringify({
400
+ assistants: [
401
+ {
402
+ assistantId: "pair-test",
403
+ runtimeUrl: LOCAL_URL,
404
+ localUrl: LOCAL_URL,
405
+ cloud: "local",
406
+ },
407
+ ],
408
+ activeAssistant: "pair-test",
409
+ }),
410
+ );
411
+
412
+ const calls: Array<[string, RequestInit | undefined]> = [];
413
+ const origFetch = globalThis.fetch;
414
+ globalThis.fetch = (async (url: string, init?: RequestInit) => {
415
+ calls.push([url, init]);
416
+ if (url === `${LOCAL_URL}/v1/assistants/pair-test/feature-flags`) {
417
+ return new Response(
418
+ JSON.stringify({
419
+ flags: [{ key: "web-remote-ingress", enabled: true }],
420
+ }),
421
+ { status: 200, headers: { "content-type": "application/json" } },
422
+ );
423
+ }
424
+ if (url === `${LOCAL_URL}/v1/remote-web/pairing-verification`) {
425
+ return new Response(
426
+ JSON.stringify({
427
+ status: "approved",
428
+ verificationUri: "https://abc123.ngrok.app/assistant/pair",
429
+ expiresAt: "2026-06-04T00:10:00.000Z",
430
+ }),
431
+ { status: 200, headers: { "content-type": "application/json" } },
432
+ );
433
+ }
434
+ return new Response("not found", { status: 404 });
435
+ }) as unknown as typeof fetch;
436
+
437
+ const logs: string[] = [];
438
+ const logSpy = spyOn(console, "log").mockImplementation(
439
+ (...a: unknown[]) => {
440
+ logs.push(a.join(" "));
441
+ },
442
+ );
443
+
444
+ process.argv = [
445
+ "bun",
446
+ "vellum",
447
+ "pair",
448
+ "--web-approve",
449
+ "ABCD-EFGH",
450
+ "--json",
451
+ ];
452
+ try {
453
+ await pair();
454
+ } finally {
455
+ logSpy.mockRestore();
456
+ globalThis.fetch = origFetch;
457
+ }
458
+
459
+ expect(calls).toHaveLength(2);
460
+ expect(calls[1][0]).toBe(`${LOCAL_URL}/v1/remote-web/pairing-verification`);
461
+ expect(JSON.parse(calls[1][1]?.body as string)).toEqual({
462
+ userCode: "ABCD-EFGH",
463
+ });
464
+ expect(JSON.parse(logs.join("\n"))).toEqual({
465
+ status: "approved",
466
+ verificationUri: "https://abc123.ngrok.app/assistant/pair",
467
+ expiresAt: "2026-06-04T00:10:00.000Z",
468
+ });
469
+ });
271
470
  });
@@ -25,6 +25,11 @@ import {
25
25
  getClientRegistrationHeaders,
26
26
  } from "../lib/client-identity.js";
27
27
  import { GATEWAY_PORT } from "../lib/constants.js";
28
+ import {
29
+ formatFeatureFlagGateMessage,
30
+ isAssistantFeatureFlagEnabled,
31
+ WEB_REMOTE_INGRESS_FLAG,
32
+ } from "../lib/feature-flags.js";
28
33
  import { getLocalLanIPv4 } from "../lib/local.js";
29
34
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
30
35
 
@@ -50,12 +55,17 @@ OPTIONS:
50
55
  --url <url> Reachable gateway URL to advertise in the bundle
51
56
  (default: the assistant's runtime URL, not loopback)
52
57
  --label <name> Human label for this pairing (echoed in the output)
58
+ --web Create a browser pairing URL for remote web access
59
+ --web-approve <code>
60
+ Approve a browser pairing code shown by /assistant/pair
53
61
  --json Output the raw bundle as JSON
54
62
 
55
63
  EXAMPLES:
56
64
  vellum pair
57
65
  vellum pair "My Assistant" --label "phone"
58
66
  vellum pair --url https://abc123.ngrok.app
67
+ vellum pair --web --url https://abc123.ngrok.app
68
+ vellum pair --web-approve ABCD-EFGH
59
69
  vellum pair --json
60
70
  `);
61
71
  }
@@ -72,6 +82,71 @@ interface PairResponse {
72
82
  refreshAfter?: string;
73
83
  }
74
84
 
85
+ interface RemoteWebPairingChallengeResponse {
86
+ deviceCode: string;
87
+ userCode: string;
88
+ verificationUri: string;
89
+ expiresAt: string;
90
+ expiresInSeconds: number;
91
+ }
92
+
93
+ interface RemoteWebPairingApprovalResponse {
94
+ status: "approved";
95
+ verificationUri: string;
96
+ expiresAt: string;
97
+ }
98
+
99
+ function normalizePublicBaseUrl(value: string): string {
100
+ const url = new URL(value);
101
+ url.search = "";
102
+ url.hash = "";
103
+ const parts = url.pathname.split("/").filter(Boolean);
104
+ const assistantIndex = parts.indexOf("assistant");
105
+ if (assistantIndex >= 0) {
106
+ parts.splice(assistantIndex);
107
+ }
108
+ url.pathname = parts.length ? `/${parts.join("/")}` : "/";
109
+ return url.toString().replace(/\/+$/, "");
110
+ }
111
+
112
+ function buildRemoteWebPairingUrl(
113
+ challenge: RemoteWebPairingChallengeResponse,
114
+ ): string {
115
+ const url = new URL(challenge.verificationUri);
116
+ url.hash = new URLSearchParams({
117
+ device_code: challenge.deviceCode,
118
+ }).toString();
119
+ return url.toString();
120
+ }
121
+
122
+ async function assertWebRemoteIngressEnabled(
123
+ assistantId: string,
124
+ runtimeUrl: string,
125
+ ): Promise<void> {
126
+ let enabled: boolean;
127
+ try {
128
+ enabled = await isAssistantFeatureFlagEnabled(
129
+ assistantId,
130
+ WEB_REMOTE_INGRESS_FLAG,
131
+ { runtimeUrl },
132
+ );
133
+ } catch (err) {
134
+ console.error(
135
+ `Error: could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
136
+ err instanceof Error ? err.message : String(err)
137
+ }`,
138
+ );
139
+ process.exit(1);
140
+ }
141
+
142
+ if (!enabled) {
143
+ console.error(
144
+ `Error: ${formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG)}`,
145
+ );
146
+ process.exit(1);
147
+ }
148
+ }
149
+
75
150
  export async function pair(): Promise<void> {
76
151
  const rawArgs = process.argv.slice(3);
77
152
 
@@ -81,12 +156,27 @@ export async function pair(): Promise<void> {
81
156
  }
82
157
 
83
158
  const jsonOutput = rawArgs.includes("--json");
84
- let args = rawArgs.filter((a) => a !== "--json");
159
+ const webPairing = rawArgs.includes("--web");
160
+ const webApproval = rawArgs.includes("--web-approve");
161
+ let args = rawArgs.filter((a) => a !== "--json" && a !== "--web");
85
162
 
86
163
  const [label, afterLabel] = extractFlag(args, "--label");
87
- const [urlOverride, afterUrl] = extractFlag(afterLabel, "--url");
164
+ const [webApproveCode, afterWebApprove] = extractFlag(
165
+ afterLabel,
166
+ "--web-approve",
167
+ );
168
+ const [urlOverride, afterUrl] = extractFlag(afterWebApprove, "--url");
88
169
  args = afterUrl;
89
170
 
171
+ if (webPairing && webApproveCode) {
172
+ console.error("Error: use either --web or --web-approve, not both.");
173
+ process.exit(1);
174
+ }
175
+ if (webApproval && !webApproveCode) {
176
+ console.error("Error: --web-approve requires a pairing code.");
177
+ process.exit(1);
178
+ }
179
+
90
180
  // Resolve the target. An explicit argument is matched by display name OR id
91
181
  // (with the standard ambiguity error); no argument falls back to the active
92
182
  // assistant. Join positional tokens so multi-word display names work even
@@ -126,7 +216,7 @@ export async function pair(): Promise<void> {
126
216
  // so without an explicit --url the bundle would point the other machine at
127
217
  // its own localhost. Refuse to advertise a loopback URL unless the user
128
218
  // explicitly passed one. (An explicit --url is trusted as-is.)
129
- if (!urlOverride && isLoopbackHost(advertisedUrl)) {
219
+ if (!urlOverride && !webApproveCode && isLoopbackHost(advertisedUrl)) {
130
220
  const lan = getLocalLanIPv4();
131
221
  // Use THIS assistant's gateway port (not the global default) — second
132
222
  // local instances listen on a different port.
@@ -149,6 +239,126 @@ export async function pair(): Promise<void> {
149
239
  process.exit(1);
150
240
  }
151
241
 
242
+ if (webApproveCode) {
243
+ await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
244
+
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;
273
+ if (jsonOutput) {
274
+ console.log(JSON.stringify(result, null, 2));
275
+ return;
276
+ }
277
+ console.log("Remote web pairing approved.");
278
+ console.log(`Expires: ${result.expiresAt}`);
279
+ return;
280
+ }
281
+
282
+ if (webPairing) {
283
+ await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
284
+
285
+ let publicBaseUrl: string;
286
+ try {
287
+ publicBaseUrl = normalizePublicBaseUrl(advertisedUrl);
288
+ } catch {
289
+ console.error(`Error: invalid --url value '${advertisedUrl}'.`);
290
+ process.exit(1);
291
+ }
292
+
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;
322
+ const pairUrl = buildRemoteWebPairingUrl(challenge);
323
+
324
+ if (jsonOutput) {
325
+ console.log(
326
+ JSON.stringify(
327
+ {
328
+ pairUrl,
329
+ userCode: challenge.userCode,
330
+ verificationUri: challenge.verificationUri,
331
+ expiresAt: challenge.expiresAt,
332
+ expiresInSeconds: challenge.expiresInSeconds,
333
+ },
334
+ null,
335
+ 2,
336
+ ),
337
+ );
338
+ return;
339
+ }
340
+
341
+ const displayName = entry.name || entry.assistantName || entry.assistantId;
342
+ console.log(`Created remote web pairing for ${displayName}.`);
343
+ console.log("");
344
+ console.log("Open this URL in the browser:");
345
+ console.log("");
346
+ console.log(` ${pairUrl}`);
347
+ console.log("");
348
+ console.log("Approve this pairing locally when you're ready:");
349
+ console.log("");
350
+ const approveTarget = assistantName
351
+ ? `${JSON.stringify(assistantName)} `
352
+ : "";
353
+ console.log(` Code: ${challenge.userCode}`);
354
+ console.log(
355
+ ` Run: vellum pair ${approveTarget}--web-approve ${challenge.userCode}`,
356
+ );
357
+ console.log("");
358
+ console.log(`Expires: ${challenge.expiresAt}`);
359
+ return;
360
+ }
361
+
152
362
  // Fresh per-pairing device identity — each `vellum pair` is independently
153
363
  // revocable.
154
364
  const deviceId = nanoid();
@@ -255,8 +255,6 @@ function buildRemoteWebIngressLocations(opts: {
255
255
  location = /v1/pair/ { return 404; }
256
256
  location = /v1/pair/web-init { return 404; }
257
257
  location = /v1/pair/web-init/ { return 404; }
258
- location = /v1/remote-web/pairing-challenge { return 404; }
259
- location = /v1/remote-web/pairing-challenge/ { return 404; }
260
258
  location = /v1/devices { return 404; }
261
259
  location = /v1/devices/ { return 404; }
262
260
  location = /v1/devices/revoke { return 404; }
@@ -265,6 +263,8 @@ function buildRemoteWebIngressLocations(opts: {
265
263
  location = /v1/guardian/init/ { return 404; }
266
264
  location = /v1/guardian/reset-bootstrap { return 404; }
267
265
  location = /v1/guardian/reset-bootstrap/ { return 404; }
266
+ location = /v1/remote-web/pairing-verification { return 404; }
267
+ location = /v1/remote-web/pairing-verification/ { return 404; }
268
268
  location ^~ /assistant/__local/ { return 404; }
269
269
  location ^~ /assistant/__gateway/ { return 404; }
270
270
 
@@ -27,6 +27,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
27
27
  fireworks: "FIREWORKS_API_KEY",
28
28
  openrouter: "OPENROUTER_API_KEY",
29
29
  minimax: "MINIMAX_API_KEY",
30
+ atlascloud: "ATLASCLOUD_API_KEY",
30
31
  };
31
32
 
32
33
  /** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */
@@ -34,6 +35,7 @@ export const SEARCH_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
34
35
  perplexity: "PERPLEXITY_API_KEY",
35
36
  brave: "BRAVE_API_KEY",
36
37
  tavily: "TAVILY_API_KEY",
38
+ firecrawl: "FIRECRAWL_API_KEY",
37
39
  };
38
40
 
39
41
  /**