@vellumai/cli 0.9.0 → 0.10.0-dev.202606192226.8ed8a6a

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/AGENTS.md CHANGED
@@ -63,7 +63,7 @@ The CLI must **never** read from or write to the `.vellum/` directory (e.g. `~/.
63
63
 
64
64
  For example, the signing key used for JWT auth between the daemon and gateway is persisted in the lockfile (`resources.signingKey`) so that client actor tokens survive daemon/gateway restarts. On first start (or when the key is missing), the CLI generates a new key via `generateLocalSigningKey()` in `lib/local.ts`, saves it to the lockfile entry, and passes it to both `startLocalDaemon` and `startGateway` as the `ACTOR_TOKEN_SIGNING_KEY` env var. The CLI does **not** read or write to the `.vellum/` directory for signing keys — it uses the lockfile instead.
65
65
 
66
- **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Swift clients, the Electron main process, the host-mode assistant, and the CLI (see `clients/shared/App/Auth/DeviceIdStore.swift` and `apps/macos/src/main/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
66
+ **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Electron main process, the host-mode assistant, and the CLI (see `clients/macos/src/main/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
67
67
 
68
68
  ## Process liveness
69
69
 
package/README.md CHANGED
@@ -4,7 +4,7 @@ CLI tools for provisioning and managing Vellum assistant instances.
4
4
 
5
5
  ## Installation
6
6
 
7
- This package is used internally by the [`vel`](https://github.com/vellum-ai/vellum-assistant-platform/tree/main/vel) CLI. You typically don't need to install it directly.
7
+ This package is used internally by the `vel` CLI. You typically don't need to install it directly.
8
8
 
9
9
  To run it standalone with [Bun](https://bun.sh):
10
10
 
@@ -25,11 +25,8 @@ function portBlock(base: number): PortMap {
25
25
  }
26
26
 
27
27
  /**
28
- * Built-in environment definitions and the TS-side source of truth for the
29
- * set of known environment names. The Swift client mirrors this list in
30
- * `clients/macos/vellum-assistant/App/VellumEnvironment.swift`; since Swift
31
- * can't import TypeScript, drift between the two is caught at test time by
32
- * `cli/src/__tests__/env-drift.test.ts`.
28
+ * Built-in environment definitions and the source of truth for the
29
+ * set of known environment names.
33
30
  *
34
31
  * Custom environments via a user config file are a future phase — see the
35
32
  * "Coexisting environments" design doc. Until then, a call site that needs a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0-dev.202606192226.8ed8a6a",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -245,6 +245,69 @@ describe("vellum backup <local>: guardian bootstrap secret", () => {
245
245
  });
246
246
  });
247
247
 
248
+ describe("vellum backup: --export-timeout flag", () => {
249
+ test("rejects a non-numeric value before any lookup", async () => {
250
+ setArgv("my-local", "--export-timeout", "soon");
251
+
252
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
253
+ () => undefined,
254
+ );
255
+ try {
256
+ await expect(backup()).rejects.toThrow("process.exit:1");
257
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
258
+ expect.stringContaining("--export-timeout must be a positive number"),
259
+ );
260
+ expect(findAssistantByNameMock).not.toHaveBeenCalled();
261
+ } finally {
262
+ consoleErrorSpy.mockRestore();
263
+ }
264
+ });
265
+
266
+ test("rejects a non-positive value", async () => {
267
+ setArgv("my-local", "--export-timeout", "0");
268
+
269
+ const consoleErrorSpy = spyOn(console, "error").mockImplementation(
270
+ () => undefined,
271
+ );
272
+ try {
273
+ await expect(backup()).rejects.toThrow("process.exit:1");
274
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
275
+ expect.stringContaining("--export-timeout must be a positive number"),
276
+ );
277
+ } finally {
278
+ consoleErrorSpy.mockRestore();
279
+ }
280
+ });
281
+
282
+ test("accepts a valid override and completes the local export", async () => {
283
+ const localEntry = {
284
+ assistantId: "local-assistant",
285
+ runtimeUrl: "http://127.0.0.1:7830",
286
+ cloud: "local",
287
+ guardianBootstrapSecret: "bootstrap-secret-value",
288
+ } satisfies assistantConfig.AssistantEntry;
289
+ findAssistantByNameMock.mockReturnValue(localEntry);
290
+ setArgv(
291
+ "my-local",
292
+ "--export-timeout",
293
+ "600",
294
+ "--output",
295
+ "/tmp/local-backup.vbundle",
296
+ );
297
+
298
+ globalThis.fetch = mock(async () => {
299
+ return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
300
+ }) as unknown as typeof globalThis.fetch;
301
+
302
+ await backup();
303
+
304
+ expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
305
+ expect(writeFileSyncMock.mock.calls[0]![0]).toBe(
306
+ "/tmp/local-backup.vbundle",
307
+ );
308
+ });
309
+ });
310
+
248
311
  describe("vellum backup <platform-managed>: GCS happy path", () => {
249
312
  test("requests upload URL → kicks off runtime export → polls → downloads from GCS → writes file", async () => {
250
313
  findAssistantByNameMock.mockReturnValue(VELLUM_ENTRY);
@@ -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
  });
@@ -84,10 +84,10 @@ describe("buildAuthorizeUrl", () => {
84
84
  });
85
85
 
86
86
  test("login hint is forwarded", () => {
87
- // generic-examples:ignore-next-line reason: test fixture for URL encoding, not a real email
88
- const url = new URL(buildAuthorizeUrl({ ...base, loginHint: "a@b.co" }));
89
- // generic-examples:ignore-next-line — reason: test fixture for URL encoding, not a real email
90
- expect(url.searchParams.get("login_hint")).toBe("a@b.co");
87
+ const url = new URL(
88
+ buildAuthorizeUrl({ ...base, loginHint: "user@example.com" }),
89
+ );
90
+ expect(url.searchParams.get("login_hint")).toBe("user@example.com");
91
91
 
92
92
  const noHint = new URL(buildAuthorizeUrl(base));
93
93
  expect(noHint.searchParams.has("login_hint")).toBe(false);
@@ -18,23 +18,32 @@ import {
18
18
  } from "../lib/platform-client.js";
19
19
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
20
20
 
21
+ // Default timeout for the runtime-direct export request. Overridable via
22
+ // --export-timeout.
23
+ const DEFAULT_EXPORT_TIMEOUT_MS = 300_000;
24
+
21
25
  export async function backup(): Promise<void> {
22
26
  const args = process.argv.slice(3);
23
27
 
24
28
  if (args.includes("--help") || args.includes("-h")) {
25
- console.log("Usage: vellum backup <name> [--output <path>]");
29
+ console.log(
30
+ "Usage: vellum backup <name> [--output <path>] [--export-timeout <seconds>]",
31
+ );
26
32
  console.log("");
27
33
  console.log(
28
34
  "Export a backup of a running assistant as a .vbundle archive.",
29
35
  );
30
36
  console.log("");
31
37
  console.log("Arguments:");
32
- console.log(" <name> Name of the assistant to back up");
38
+ console.log(" <name> Name of the assistant to back up");
33
39
  console.log("");
34
40
  console.log("Options:");
35
- console.log(" --output <path> Path to save the .vbundle file");
41
+ console.log(" --output <path> Path to save the .vbundle file");
36
42
  console.log(
37
- " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
43
+ " (default: ~/.local/share/vellum/backups/<name>-<timestamp>.vbundle)",
44
+ );
45
+ console.log(
46
+ ` --export-timeout <secs> Export request timeout in seconds (default: ${DEFAULT_EXPORT_TIMEOUT_MS / 1000})`,
38
47
  );
39
48
  console.log("");
40
49
  console.log("Examples:");
@@ -42,21 +51,33 @@ export async function backup(): Promise<void> {
42
51
  console.log(
43
52
  " vellum backup my-assistant --output ~/Desktop/backup.vbundle",
44
53
  );
54
+ console.log(" vellum backup my-assistant --export-timeout 600");
45
55
  process.exit(0);
46
56
  }
47
57
 
48
58
  const name = args[0];
49
59
  if (!name || name.startsWith("-")) {
50
- console.error("Usage: vellum backup <name> [--output <path>]");
60
+ console.error(
61
+ "Usage: vellum backup <name> [--output <path>] [--export-timeout <seconds>]",
62
+ );
51
63
  process.exit(1);
52
64
  }
53
65
 
54
- // Parse --output flag
66
+ // Parse flags
55
67
  let outputArg: string | undefined;
68
+ let exportTimeoutMs = DEFAULT_EXPORT_TIMEOUT_MS;
56
69
  for (let i = 1; i < args.length; i++) {
57
70
  if (args[i] === "--output" && args[i + 1]) {
58
71
  outputArg = args[i + 1];
59
- break;
72
+ } else if (args[i] === "--export-timeout" && args[i + 1]) {
73
+ const seconds = Number(args[i + 1]);
74
+ if (!Number.isFinite(seconds) || seconds <= 0) {
75
+ console.error(
76
+ `Error: --export-timeout must be a positive number of seconds (got '${args[i + 1]}').`,
77
+ );
78
+ process.exit(1);
79
+ }
80
+ exportTimeoutMs = seconds * 1000;
60
81
  }
61
82
  }
62
83
 
@@ -120,7 +141,7 @@ export async function backup(): Promise<void> {
120
141
  "Content-Type": "application/json",
121
142
  },
122
143
  body: JSON.stringify({ description: "CLI backup" }),
123
- signal: AbortSignal.timeout(120_000),
144
+ signal: AbortSignal.timeout(exportTimeoutMs),
124
145
  });
125
146
 
126
147
  // Retry once with a fresh token on 401 — the cached token may be stale
@@ -146,13 +167,15 @@ export async function backup(): Promise<void> {
146
167
  "Content-Type": "application/json",
147
168
  },
148
169
  body: JSON.stringify({ description: "CLI backup" }),
149
- signal: AbortSignal.timeout(120_000),
170
+ signal: AbortSignal.timeout(exportTimeoutMs),
150
171
  });
151
172
  }
152
173
  }
153
174
  } catch (err) {
154
175
  if (err instanceof Error && err.name === "TimeoutError") {
155
- console.error("Error: Export request timed out after 2 minutes.");
176
+ console.error(
177
+ `Error: Export request timed out after ${exportTimeoutMs / 1000} seconds.`,
178
+ );
156
179
  process.exit(1);
157
180
  }
158
181
  const msg = err instanceof Error ? err.message : String(err);
@@ -340,7 +340,7 @@ const SPA_BASE = "/assistant/";
340
340
  *
341
341
  * Resolution order:
342
342
  * 1. npm-installed package — require.resolve('@vellumai/web/package.json')
343
- * 2. Source checkout — walk up from cli/ to find apps/web/dist/
343
+ * 2. Source checkout — walk up from cli/ to find clients/web/dist/
344
344
  */
345
345
  function findWebDistDir(): string | null {
346
346
  try {
@@ -355,7 +355,7 @@ function findWebDistDir(): string | null {
355
355
 
356
356
  let dir = import.meta.dir;
357
357
  for (let depth = 0; depth < 8; depth++) {
358
- const candidate = path.join(dir, "apps", "web", "dist", "index.html");
358
+ const candidate = path.join(dir, "clients", "web", "dist", "index.html");
359
359
  if (existsSync(candidate)) {
360
360
  return path.dirname(candidate);
361
361
  }
@@ -367,13 +367,13 @@ function findWebDistDir(): string | null {
367
367
  }
368
368
 
369
369
  /**
370
- * Locate the apps/web source directory for running the Vite dev server.
370
+ * Locate the clients/web source directory for running the Vite dev server.
371
371
  * Only works from a source checkout (not npm-installed).
372
372
  */
373
373
  function findWebSourceDir(): string | null {
374
374
  let dir = import.meta.dir;
375
375
  for (let depth = 0; depth < 8; depth++) {
376
- const candidate = path.join(dir, "apps", "web", "vite.config.ts");
376
+ const candidate = path.join(dir, "clients", "web", "vite.config.ts");
377
377
  if (existsSync(candidate)) {
378
378
  return path.dirname(candidate);
379
379
  }
@@ -679,7 +679,7 @@ async function runWebInterface(
679
679
  `${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
680
680
  `@vellumai/web assets.\n\n` +
681
681
  ` npm/bunx install: npm install @vellumai/web\n` +
682
- ` source checkout: cd apps/web && VITE_PLATFORM_MODE=false bun run build`,
682
+ ` source checkout: cd clients/web && VITE_PLATFORM_MODE=false bun run build`,
683
683
  );
684
684
  process.exit(1);
685
685
  }
@@ -195,7 +195,7 @@ async function up(target: NginxIngressTarget): Promise<void> {
195
195
  );
196
196
  console.error("");
197
197
  console.error("Build the SPA first:");
198
- console.error(" cd apps/web && VITE_PLATFORM_MODE=false bun run build");
198
+ console.error(" cd clients/web && VITE_PLATFORM_MODE=false bun run build");
199
199
  console.error("");
200
200
  console.error(
201
201
  "Or install @vellumai/web so its packaged dist directory is available.",
@@ -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();
@@ -318,7 +318,7 @@ async function retireInner(): Promise<void> {
318
318
  console.log(`Removed ${formatAssistantReference(entry)} from config.`);
319
319
 
320
320
  // When no assistants remain, remove the dock-display-name sentinel so
321
- // the next build.sh run falls back to "Vellum" instead of using the
321
+ // the dock label falls back to "Vellum" instead of using the
322
322
  // retired assistant's name.
323
323
  if (loadAllAssistants().length === 0) {
324
324
  const dockLabelFile = join(
@@ -1012,7 +1012,7 @@ async function upgradePlatform(
1012
1012
  }
1013
1013
 
1014
1014
  /**
1015
- * Pre-upgrade steps for Sparkle (macOS app) lifecycle.
1015
+ * Pre-upgrade steps for the macOS app upgrade lifecycle.
1016
1016
  * Runs the pre-update orchestration without actually swapping containers:
1017
1017
  * broadcasts SSE events, creates a workspace commit, creates a backup,
1018
1018
  * prunes old backups, and outputs the backup path.
@@ -1035,7 +1035,7 @@ async function upgradePrepare(
1035
1035
  await commitWorkspaceViaGateway(
1036
1036
  entry.runtimeUrl,
1037
1037
  entry.assistantId,
1038
- `[sparkle-update] Starting: ${currentVersion} → ${targetVersion}`,
1038
+ `[assistant-upgrade] Starting: ${currentVersion} → ${targetVersion}`,
1039
1039
  );
1040
1040
 
1041
1041
  // 3. Progress: saving backup
@@ -1070,7 +1070,7 @@ async function upgradePrepare(
1070
1070
  }
1071
1071
 
1072
1072
  /**
1073
- * Post-upgrade steps for Sparkle (macOS app) lifecycle.
1073
+ * Post-upgrade steps for the macOS app upgrade lifecycle.
1074
1074
  * Called after the app has been replaced and the daemon is back up.
1075
1075
  * Broadcasts a "complete" SSE event and creates a workspace commit.
1076
1076
  */
@@ -1103,7 +1103,7 @@ async function upgradeFinalize(
1103
1103
  await commitWorkspaceViaGateway(
1104
1104
  entry.runtimeUrl,
1105
1105
  entry.assistantId,
1106
- `[sparkle-update] Complete: ${fromVersion} → ${currentVersion}\n\nresult: success`,
1106
+ `[assistant-upgrade] Complete: ${fromVersion} → ${currentVersion}\n\nresult: success`,
1107
1107
  );
1108
1108
  }
1109
1109
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host device ID resolver. Resolution order: `VELLUM_DEVICE_ID` env var,
3
3
  * then `device.json`. Production uses the machine-wide shared
4
- * `~/.vellum/device.json`, matching Electron (`apps/macos/src/main/device-id.ts`)
4
+ * `~/.vellum/device.json`, matching Electron (`clients/macos/src/main/device-id.ts`)
5
5
  * and Swift (`VellumPaths.deviceIdFile`); non-production uses
6
6
  * `<configDir>/device.json`.
7
7
  *
@@ -87,10 +87,9 @@ export function getCurrentEnvironment(
87
87
  if (!seed) {
88
88
  if (name !== DEFAULT_ENVIRONMENT_NAME) {
89
89
  // Warn on stderr instead of throwing, to match the silent-fallback
90
- // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName
91
- // and clients/shared/App/VellumEnvironment.swift:current. Those two
92
- // silently fall back to production; the CLI should agree so all three
93
- // writers don't end up in disjoint states on a typo.
90
+ // behavior in assistant/src/util/platform.ts:getXdgVellumConfigDirName,
91
+ // which silently falls back to production; the CLI agrees so neither
92
+ // writer ends up in a disjoint state on a typo.
94
93
  process.stderr.write(
95
94
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
96
95
  `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
package/src/lib/local.ts CHANGED
@@ -1018,7 +1018,7 @@ export async function startLocalDaemon(
1018
1018
  let daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
1019
1019
 
1020
1020
  // Dev fallback: if the bundled daemon did not become ready in time,
1021
- // fall back to source daemon startup so local `./build.sh run` still works.
1021
+ // fall back to source daemon startup so local source runs still work.
1022
1022
  if (!daemonReady) {
1023
1023
  const assistantIndex = resolveAssistantIndexPath();
1024
1024
  if (assistantIndex) {
@@ -85,7 +85,7 @@ function saveRawConfig(
85
85
  *
86
86
  * Resolution order:
87
87
  * 1. npm-installed package — require.resolve('@vellumai/web/package.json')
88
- * 2. Source checkout — walk up from cli/ to find apps/web/dist/
88
+ * 2. Source checkout — walk up from cli/ to find clients/web/dist/
89
89
  */
90
90
  export function findWebDistDir(): string | null {
91
91
  try {
@@ -100,7 +100,7 @@ export function findWebDistDir(): string | null {
100
100
 
101
101
  let dir = import.meta.dir;
102
102
  for (let depth = 0; depth < 8; depth++) {
103
- const candidate = join(dir, "apps", "web", "dist", "index.html");
103
+ const candidate = join(dir, "clients", "web", "dist", "index.html");
104
104
  if (existsSync(candidate)) {
105
105
  return dirname(candidate);
106
106
  }
@@ -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
  /**
@@ -1,53 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
-
5
- import { SEEDS } from "@vellumai/environments";
6
-
7
- // Drift guard between the two language-level sources of truth for the set of
8
- // known environment names:
9
- //
10
- // 1. packages/environments/src/seeds.ts — SEEDS record (TS source of truth)
11
- // 2. clients/shared/App/VellumEnvironment.swift — Swift `VellumEnvironment` enum
12
- //
13
- // The Swift client can't import the TypeScript package, so the two lists are
14
- // maintained independently and must be kept in lockstep by hand. This test
15
- // parses the enum cases out of the Swift source and asserts they agree with
16
- // SEEDS. Adding an environment means updating both sites.
17
-
18
- const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
19
- const SWIFT_ENVIRONMENT = join(
20
- REPO_ROOT,
21
- "clients",
22
- "shared",
23
- "App",
24
- "VellumEnvironment.swift",
25
- );
26
-
27
- /**
28
- * Extract the case names declared in the `VellumEnvironment` enum. Matches
29
- * standalone `case <name>` declaration lines (one identifier, nothing else),
30
- * which is the enum's own declaration syntax. Switch-statement arms like
31
- * `case .local:` carry a leading dot and a trailing colon, so they're
32
- * excluded — the match is anchored to a bare identifier at end of line.
33
- */
34
- function extractSwiftEnumCases(source: string): string[] {
35
- const names: string[] = [];
36
- for (const line of source.split("\n")) {
37
- const match = line.match(/^\s*case\s+([a-zA-Z][a-zA-Z0-9]*)\s*$/);
38
- if (match) names.push(match[1]!);
39
- }
40
- return names;
41
- }
42
-
43
- describe("environment name drift guard (TS ↔ Swift)", () => {
44
- const seedNames = new Set(Object.keys(SEEDS));
45
-
46
- test("clients/shared/App/VellumEnvironment.swift matches SEEDS", () => {
47
- const source = readFileSync(SWIFT_ENVIRONMENT, "utf8");
48
- const swiftNames = new Set(extractSwiftEnumCases(source));
49
-
50
- expect(swiftNames.size).toBeGreaterThan(0);
51
- expect([...swiftNames].sort()).toEqual([...seedNames].sort());
52
- });
53
- });