@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 +1 -1
- package/README.md +1 -1
- package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
- package/package.json +1 -1
- package/src/__tests__/backup.test.ts +63 -0
- package/src/__tests__/nginx-ingress.test.ts +0 -2
- package/src/__tests__/pair.test.ts +199 -0
- package/src/__tests__/workos-pkce.test.ts +4 -4
- package/src/commands/backup.ts +33 -10
- package/src/commands/client.ts +5 -5
- package/src/commands/nginx-ingress.ts +1 -1
- package/src/commands/pair.ts +213 -3
- package/src/commands/retire.ts +1 -1
- package/src/commands/upgrade.ts +4 -4
- package/src/lib/device-id.ts +1 -1
- package/src/lib/environments/resolve.ts +3 -4
- package/src/lib/local.ts +1 -1
- package/src/lib/nginx-ingress.ts +4 -4
- package/src/shared/provider-env-vars.ts +2 -0
- package/src/__tests__/env-drift.test.ts +0 -53
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
|
|
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
|
|
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
|
|
29
|
-
* set of known environment names.
|
|
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
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(url.searchParams.get("login_hint")).toBe("
|
|
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);
|
package/src/commands/backup.ts
CHANGED
|
@@ -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(
|
|
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>
|
|
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>
|
|
41
|
+
console.log(" --output <path> Path to save the .vbundle file");
|
|
36
42
|
console.log(
|
|
37
|
-
"
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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);
|
package/src/commands/client.ts
CHANGED
|
@@ -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
|
|
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, "
|
|
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
|
|
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, "
|
|
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
|
|
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
|
|
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.",
|
package/src/commands/pair.ts
CHANGED
|
@@ -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
|
-
|
|
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 [
|
|
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();
|
package/src/commands/retire.ts
CHANGED
|
@@ -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
|
|
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(
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -1012,7 +1012,7 @@ async function upgradePlatform(
|
|
|
1012
1012
|
}
|
|
1013
1013
|
|
|
1014
1014
|
/**
|
|
1015
|
-
* Pre-upgrade steps for
|
|
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
|
-
`[
|
|
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
|
|
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
|
-
`[
|
|
1106
|
+
`[assistant-upgrade] Complete: ${fromVersion} → ${currentVersion}\n\nresult: success`,
|
|
1107
1107
|
);
|
|
1108
1108
|
}
|
|
1109
1109
|
|
package/src/lib/device-id.ts
CHANGED
|
@@ -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 (`
|
|
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
|
-
//
|
|
92
|
-
//
|
|
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
|
|
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) {
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -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
|
|
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, "
|
|
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
|
-
});
|