@vellumai/cli 0.8.7 → 0.8.8-dev.202606052332.17fc8ea

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.
Files changed (49) hide show
  1. package/node_modules/@vellumai/local-mode/package.json +2 -1
  2. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +15 -0
  5. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  6. package/node_modules/@vellumai/local-mode/src/config.ts +15 -8
  7. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  8. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +42 -0
  9. package/node_modules/@vellumai/local-mode/src/hatch.ts +22 -4
  10. package/node_modules/@vellumai/local-mode/src/index.ts +26 -4
  11. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  12. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  13. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  14. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -7
  15. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  16. package/package.json +1 -1
  17. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  18. package/src/__tests__/clean.test.ts +179 -0
  19. package/src/__tests__/client-token.test.ts +87 -0
  20. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  21. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  22. package/src/__tests__/connect-import.test.ts +317 -0
  23. package/src/__tests__/devices.test.ts +272 -0
  24. package/src/__tests__/guardian-token.test.ts +126 -2
  25. package/src/__tests__/pair.test.ts +271 -0
  26. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  27. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  28. package/src/__tests__/unpair.test.ts +163 -0
  29. package/src/commands/client.ts +115 -26
  30. package/src/commands/connect/import.ts +217 -0
  31. package/src/commands/connect.ts +31 -0
  32. package/src/commands/devices.ts +247 -0
  33. package/src/commands/pair.ts +222 -0
  34. package/src/commands/ps.ts +16 -0
  35. package/src/commands/retire.ts +20 -47
  36. package/src/commands/sleep.ts +7 -0
  37. package/src/commands/tunnel.ts +46 -2
  38. package/src/commands/unpair.ts +118 -0
  39. package/src/commands/wake.ts +7 -0
  40. package/src/components/DefaultMainScreen.tsx +84 -13
  41. package/src/index.ts +16 -0
  42. package/src/lib/assistant-client.ts +58 -37
  43. package/src/lib/assistant-config.ts +12 -0
  44. package/src/lib/cloudflare-tunnel.ts +276 -0
  45. package/src/lib/confirm-action.ts +57 -0
  46. package/src/lib/docker.ts +25 -1
  47. package/src/lib/environments/resolve.ts +9 -30
  48. package/src/lib/guardian-token.ts +120 -4
  49. package/src/lib/local.ts +20 -6
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Tests for `vellum devices` (list) and `vellum devices revoke <hashedDeviceId>`:
3
+ * the host-side CLI that calls the loopback `GET /v1/devices` and
4
+ * `POST /v1/devices/revoke` endpoints. Verifies host-gating (refuses paired
5
+ * connections), the destructive-revoke confirmation, and that requests carry no
6
+ * browser/proxy headers.
7
+ */
8
+ import {
9
+ afterAll,
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ spyOn,
15
+ test,
16
+ } from "bun:test";
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ const testDir = mkdtempSync(join(tmpdir(), "devices-test-"));
22
+ const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
23
+ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
24
+ const ORIGINAL_ARGV = [...process.argv];
25
+ const ORIGINAL_FETCH = globalThis.fetch;
26
+
27
+ import { devices } from "../commands/devices.js";
28
+ import { saveAssistantEntry } from "../lib/assistant-config.js";
29
+
30
+ interface FetchCall {
31
+ url: string;
32
+ init?: RequestInit;
33
+ }
34
+
35
+ let fetchCalls: FetchCall[] = [];
36
+
37
+ /** Stub global fetch (spyOn does not intercept fetch in Bun). */
38
+ function stubFetch(
39
+ handler: (url: string, init?: RequestInit) => Response,
40
+ ): void {
41
+ globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
42
+ fetchCalls.push({ url: String(url), init });
43
+ return handler(String(url), init);
44
+ }) as typeof fetch;
45
+ }
46
+
47
+ function jsonResponse(body: unknown, status = 200): Response {
48
+ return new Response(JSON.stringify(body), {
49
+ status,
50
+ headers: { "Content-Type": "application/json" },
51
+ });
52
+ }
53
+
54
+ interface RunResult {
55
+ exited: boolean;
56
+ logs: string;
57
+ errors: string;
58
+ }
59
+
60
+ /** Run devices() with console + process.exit spied. */
61
+ async function runDevices(): Promise<RunResult> {
62
+ const logs: string[] = [];
63
+ const errors: string[] = [];
64
+ const logSpy = spyOn(console, "log").mockImplementation((...a: unknown[]) => {
65
+ logs.push(a.join(" "));
66
+ });
67
+ const errSpy = spyOn(console, "error").mockImplementation(
68
+ (...a: unknown[]) => {
69
+ errors.push(a.join(" "));
70
+ },
71
+ );
72
+ const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
73
+ throw new Error(`exit:${c}`);
74
+ }) as never);
75
+ let exited = false;
76
+ try {
77
+ await devices();
78
+ } catch (e) {
79
+ exited = (e as Error).message?.startsWith("exit:") ?? false;
80
+ } finally {
81
+ logSpy.mockRestore();
82
+ errSpy.mockRestore();
83
+ exitSpy.mockRestore();
84
+ }
85
+ return { exited, logs: logs.join("\n"), errors: errors.join("\n") };
86
+ }
87
+
88
+ function headerKeys(init?: RequestInit): string[] {
89
+ const h = init?.headers as Record<string, string> | undefined;
90
+ return h ? Object.keys(h).map((k) => k.toLowerCase()) : [];
91
+ }
92
+
93
+ describe("vellum devices", () => {
94
+ beforeEach(() => {
95
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
96
+ process.env.XDG_CONFIG_HOME = testDir;
97
+ fetchCalls = [];
98
+ // Default stub: any unexpected call is recorded and 500s.
99
+ stubFetch(() => jsonResponse({ error: "unexpected" }, 500));
100
+ });
101
+
102
+ afterEach(() => {
103
+ process.argv = [...ORIGINAL_ARGV];
104
+ globalThis.fetch = ORIGINAL_FETCH;
105
+ if (ORIGINAL_LOCKFILE_DIR === undefined)
106
+ delete process.env.VELLUM_LOCKFILE_DIR;
107
+ else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
108
+ if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
109
+ else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
110
+ });
111
+
112
+ afterAll(() => {
113
+ rmSync(testDir, { recursive: true, force: true });
114
+ });
115
+
116
+ function seedLocal(id: string, localUrl = "http://127.0.0.1:7830"): void {
117
+ saveAssistantEntry({
118
+ assistantId: id,
119
+ name: id,
120
+ runtimeUrl: "http://127.0.0.1:7830",
121
+ localUrl,
122
+ cloud: "local",
123
+ species: "vellum",
124
+ });
125
+ }
126
+
127
+ test("--help prints usage including Examples", async () => {
128
+ process.argv = ["bun", "vellum", "devices", "--help"];
129
+ const { logs } = await runDevices();
130
+ expect(logs).toContain("USAGE:");
131
+ expect(logs).toContain("EXAMPLES:");
132
+ expect(logs).toContain("vellum devices revoke");
133
+ });
134
+
135
+ test("lists active devices over loopback with no browser/proxy headers", async () => {
136
+ seedLocal("list-host", "http://127.0.0.1:7833");
137
+ stubFetch((url) => {
138
+ if (url.endsWith("/v1/devices")) {
139
+ return jsonResponse({
140
+ devices: [
141
+ {
142
+ hashedDeviceId: "hashAAA111",
143
+ platform: "cli",
144
+ issuedAt: 1_700_000_000_000,
145
+ expiresAt: 1_800_000_000_000,
146
+ lastUsedAt: 1_750_000_000_000,
147
+ },
148
+ {
149
+ hashedDeviceId: "hashBBB222",
150
+ platform: "webview",
151
+ issuedAt: 1_700_000_000_000,
152
+ expiresAt: null,
153
+ lastUsedAt: null,
154
+ },
155
+ ],
156
+ });
157
+ }
158
+ return jsonResponse({ error: "unexpected" }, 500);
159
+ });
160
+
161
+ process.argv = ["bun", "vellum", "devices", "list-host"];
162
+ const { exited, logs } = await runDevices();
163
+
164
+ expect(exited).toBe(false);
165
+ // Both full hashes + platforms surfaced; null lastUsedAt → "never".
166
+ expect(logs).toContain("hashAAA111");
167
+ expect(logs).toContain("hashBBB222");
168
+ expect(logs).toContain("cli");
169
+ expect(logs).toContain("webview");
170
+ expect(logs).toContain("never");
171
+
172
+ expect(fetchCalls).toHaveLength(1);
173
+ const call = fetchCalls[0];
174
+ expect(call.url).toBe("http://127.0.0.1:7833/v1/devices");
175
+ expect(call.init?.method).toBe("GET");
176
+ const keys = headerKeys(call.init);
177
+ expect(keys).not.toContain("origin");
178
+ expect(keys).not.toContain("x-forwarded-for");
179
+ });
180
+
181
+ test("prints a clear message when no devices are paired", async () => {
182
+ seedLocal("empty-host");
183
+ stubFetch(() => jsonResponse({ devices: [] }));
184
+
185
+ process.argv = ["bun", "vellum", "devices", "empty-host"];
186
+ const { exited, logs } = await runDevices();
187
+
188
+ expect(exited).toBe(false);
189
+ expect(logs).toContain("No devices are paired to empty-host");
190
+ });
191
+
192
+ test("revoke posts the hashedDeviceId with --yes (no prompt)", async () => {
193
+ seedLocal("revoke-host", "http://127.0.0.1:7834");
194
+ stubFetch((url) => {
195
+ if (url.endsWith("/v1/devices/revoke")) {
196
+ return jsonResponse({ revoked: true, hashedDeviceId: "hashAAA111" });
197
+ }
198
+ return jsonResponse({ error: "unexpected" }, 500);
199
+ });
200
+
201
+ process.argv = [
202
+ "bun",
203
+ "vellum",
204
+ "devices",
205
+ "revoke",
206
+ "hashAAA111",
207
+ "revoke-host",
208
+ "--yes",
209
+ ];
210
+ const { exited, logs } = await runDevices();
211
+
212
+ expect(exited).toBe(false);
213
+ expect(logs).toContain("Revoked device hashAAA111");
214
+
215
+ expect(fetchCalls).toHaveLength(1);
216
+ const call = fetchCalls[0];
217
+ expect(call.url).toBe("http://127.0.0.1:7834/v1/devices/revoke");
218
+ expect(call.init?.method).toBe("POST");
219
+ expect(JSON.parse(String(call.init?.body))).toEqual({
220
+ hashedDeviceId: "hashAAA111",
221
+ });
222
+ });
223
+
224
+ test("revoke without a hashedDeviceId errors and makes no request", async () => {
225
+ process.argv = ["bun", "vellum", "devices", "revoke", "--yes"];
226
+ const { exited, errors } = await runDevices();
227
+
228
+ expect(exited).toBe(true);
229
+ expect(errors).toContain("hashedDeviceId is required");
230
+ expect(fetchCalls).toHaveLength(0);
231
+ });
232
+
233
+ test("revoke refuses without --yes in a non-interactive terminal", async () => {
234
+ seedLocal("rh3");
235
+ // process.stdin.isTTY is falsy under the test runner → not promptable.
236
+ process.argv = ["bun", "vellum", "devices", "revoke", "hashZZZ", "rh3"];
237
+ const { exited, errors } = await runDevices();
238
+
239
+ expect(exited).toBe(true);
240
+ expect(errors).toContain("--yes");
241
+ expect(fetchCalls).toHaveLength(0);
242
+ });
243
+
244
+ test("host-gates a paired connection (points to the host / unpair)", async () => {
245
+ saveAssistantEntry({
246
+ assistantId: "paired-box",
247
+ name: "Paired Box",
248
+ runtimeUrl: "http://10.0.0.9:7830",
249
+ cloud: "paired",
250
+ paired: true,
251
+ species: "vellum",
252
+ });
253
+
254
+ process.argv = ["bun", "vellum", "devices", "paired-box"];
255
+ const { exited, errors } = await runDevices();
256
+
257
+ expect(exited).toBe(true);
258
+ expect(errors).toContain("vellum unpair");
259
+ expect(fetchCalls).toHaveLength(0);
260
+ });
261
+
262
+ test("surfaces a non-2xx gateway response on list", async () => {
263
+ seedLocal("err-host");
264
+ stubFetch(() => jsonResponse({ error: { code: "FORBIDDEN" } }, 403));
265
+
266
+ process.argv = ["bun", "vellum", "devices", "err-host"];
267
+ const { exited, errors } = await runDevices();
268
+
269
+ expect(exited).toBe(true);
270
+ expect(errors).toContain("403");
271
+ });
272
+ });
@@ -1,11 +1,20 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ utimesSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
12
+ import { dirname, join } from "node:path";
5
13
 
6
14
  import {
7
15
  getOrCreatePersistedDeviceId,
8
16
  loadGuardianToken,
17
+ refreshGuardianToken,
9
18
  saveGuardianToken,
10
19
  seedGuardianTokenFromSiblingEnv,
11
20
  type GuardianTokenData,
@@ -223,3 +232,118 @@ describe("guardian-token paths are env-scoped", () => {
223
232
  );
224
233
  });
225
234
  });
235
+
236
+ describe("refreshGuardianToken", () => {
237
+ let tempHome: string;
238
+ let savedXdg: string | undefined;
239
+ let savedEnv: string | undefined;
240
+ const ORIGINAL_FETCH = globalThis.fetch;
241
+
242
+ const future = () => new Date(Date.now() + 60 * 60 * 1000).toISOString();
243
+
244
+ function lockPath(id: string): string {
245
+ return join(tempHome, "vellum", "assistants", id, "refresh.lock");
246
+ }
247
+
248
+ function seed(refreshExpiresAt: string | number): void {
249
+ saveGuardianToken("px", {
250
+ guardianPrincipalId: "imported",
251
+ accessToken: "old-acc",
252
+ accessTokenExpiresAt: future(),
253
+ refreshToken: "old-ref",
254
+ refreshTokenExpiresAt: refreshExpiresAt,
255
+ refreshAfter: "",
256
+ isNew: false,
257
+ deviceId: "dev",
258
+ leasedAt: new Date().toISOString(),
259
+ });
260
+ }
261
+
262
+ beforeEach(() => {
263
+ savedXdg = process.env.XDG_CONFIG_HOME;
264
+ savedEnv = process.env.VELLUM_ENVIRONMENT;
265
+ tempHome = mkdtempSync(join(tmpdir(), "cli-refresh-test-"));
266
+ process.env.XDG_CONFIG_HOME = tempHome;
267
+ delete process.env.VELLUM_ENVIRONMENT;
268
+ });
269
+
270
+ afterEach(() => {
271
+ globalThis.fetch = ORIGINAL_FETCH;
272
+ if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
273
+ else process.env.XDG_CONFIG_HOME = savedXdg;
274
+ if (savedEnv === undefined) delete process.env.VELLUM_ENVIRONMENT;
275
+ else process.env.VELLUM_ENVIRONMENT = savedEnv;
276
+ rmSync(tempHome, { recursive: true, force: true });
277
+ });
278
+
279
+ test("refreshes, persists the rotated token, sends an abort signal, releases the lock", async () => {
280
+ seed(future());
281
+ let sawSignal = false;
282
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
283
+ sawSignal = init?.signal instanceof AbortSignal;
284
+ return new Response(
285
+ JSON.stringify({
286
+ accessToken: "new-acc",
287
+ refreshToken: "new-ref",
288
+ accessTokenExpiresAt: future(),
289
+ refreshTokenExpiresAt: future(),
290
+ refreshAfter: "",
291
+ }),
292
+ { status: 200, headers: { "content-type": "application/json" } },
293
+ );
294
+ }) as typeof fetch;
295
+
296
+ const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
297
+
298
+ expect(result?.accessToken).toBe("new-acc");
299
+ expect(loadGuardianToken("px")?.accessToken).toBe("new-acc");
300
+ expect(sawSignal).toBe(true); // fetch carries a timeout AbortSignal
301
+ expect(existsSync(lockPath("px"))).toBe(false); // lock released
302
+ });
303
+
304
+ test("returns null without calling the gateway when the refresh token is expired", async () => {
305
+ seed(new Date(Date.now() - 1000).toISOString());
306
+ let called = false;
307
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
308
+ called = true;
309
+ return new Response("", { status: 200 });
310
+ }) as typeof fetch;
311
+
312
+ expect(await refreshGuardianToken("http://10.0.0.9:7830", "px")).toBeNull();
313
+ expect(called).toBe(false);
314
+ });
315
+
316
+ test("returns null when there is no stored token", async () => {
317
+ let called = false;
318
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) => {
319
+ called = true;
320
+ return new Response("", { status: 200 });
321
+ }) as typeof fetch;
322
+
323
+ expect(
324
+ await refreshGuardianToken("http://10.0.0.9:7830", "missing"),
325
+ ).toBeNull();
326
+ expect(called).toBe(false);
327
+ });
328
+
329
+ test("steals a stale lock and still refreshes", async () => {
330
+ seed(future());
331
+ // Pre-create a stale lock (mtime well in the past) as if a crashed holder
332
+ // left it behind; the refresh must steal it rather than block.
333
+ const lp = lockPath("px");
334
+ mkdirSync(dirname(lp), { recursive: true });
335
+ writeFileSync(lp, "99999");
336
+ const old = new Date(Date.now() - 60_000);
337
+ utimesSync(lp, old, old);
338
+
339
+ globalThis.fetch = (async (_url: unknown, _init?: RequestInit) =>
340
+ new Response(JSON.stringify({ accessToken: "new-acc" }), {
341
+ status: 200,
342
+ headers: { "content-type": "application/json" },
343
+ })) as typeof fetch;
344
+
345
+ const result = await refreshGuardianToken("http://10.0.0.9:7830", "px");
346
+ expect(result?.accessToken).toBe("new-acc");
347
+ expect(existsSync(lp)).toBe(false); // stolen lock cleaned up after release
348
+ });
349
+ });
@@ -0,0 +1,271 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ // Real assistant-config reads the lockfile from VELLUM_LOCKFILE_DIR.
15
+ const testDir = mkdtempSync(join(tmpdir(), "pair-command-test-"));
16
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
17
+
18
+ import { pair } from "../commands/pair.js";
19
+
20
+ // Distinct loopback (mint) vs reachable (advertised) URLs to verify the split.
21
+ const LOCAL_URL = "http://127.0.0.1:7830";
22
+ const RUNTIME_URL = "http://192.168.1.50:7830";
23
+
24
+ function writeLockfile(): void {
25
+ writeFileSync(
26
+ join(testDir, ".vellum.lock.json"),
27
+ JSON.stringify({
28
+ assistants: [
29
+ {
30
+ assistantId: "pair-test",
31
+ runtimeUrl: RUNTIME_URL,
32
+ localUrl: LOCAL_URL,
33
+ cloud: "local",
34
+ },
35
+ ],
36
+ activeAssistant: "pair-test",
37
+ }),
38
+ );
39
+ }
40
+
41
+ // Capture the real argv ONCE, before any test mutates it, and restore after
42
+ // every test — so a `['bun','vellum','pair',...]` argv can't leak into other
43
+ // test files in the same Bun run.
44
+ const ORIGINAL_ARGV = [...process.argv];
45
+
46
+ describe("pair command", () => {
47
+ beforeEach(() => {
48
+ writeLockfile();
49
+ });
50
+
51
+ afterEach(() => {
52
+ process.argv = [...ORIGINAL_ARGV];
53
+ });
54
+
55
+ afterAll(() => {
56
+ rmSync(testDir, { recursive: true, force: true });
57
+ delete process.env.VELLUM_LOCKFILE_DIR;
58
+ });
59
+
60
+ test("POSTs a cli device-bound pair request and prints a decodable bundle", async () => {
61
+ const calls: Array<[string, RequestInit]> = [];
62
+ const origFetch = globalThis.fetch;
63
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
64
+ calls.push([url, init]);
65
+ return new Response(
66
+ JSON.stringify({
67
+ token: "test-access-token",
68
+ expiresAt: "2026-06-04T00:00:00.000Z",
69
+ guardianId: "guardian-001",
70
+ assistantId: "self",
71
+ }),
72
+ { status: 200, headers: { "content-type": "application/json" } },
73
+ );
74
+ }) as unknown as typeof fetch;
75
+
76
+ const logs: string[] = [];
77
+ const logSpy = spyOn(console, "log").mockImplementation(
78
+ (...a: unknown[]) => {
79
+ logs.push(a.join(" "));
80
+ },
81
+ );
82
+
83
+ process.argv = ["bun", "vellum", "pair", "--json"];
84
+ try {
85
+ await pair();
86
+ } finally {
87
+ logSpy.mockRestore();
88
+ globalThis.fetch = origFetch;
89
+ }
90
+
91
+ // Mint over the loopback (localUrl), not the reachable runtime URL.
92
+ expect(calls).toHaveLength(1);
93
+ const [url, init] = calls[0];
94
+ expect(url).toBe(`${LOCAL_URL}/v1/pair`);
95
+ expect(init.method).toBe("POST");
96
+ const headers = Object.fromEntries(
97
+ Object.entries(init.headers as Record<string, string>).map(([k, v]) => [
98
+ k.toLowerCase(),
99
+ v,
100
+ ]),
101
+ );
102
+ expect(headers["x-vellum-interface-id"]).toBe("cli");
103
+ const body = JSON.parse(init.body as string);
104
+ expect(typeof body.deviceId).toBe("string");
105
+ expect(body.deviceId.length).toBeGreaterThan(0);
106
+ expect(body.platform).toBe("cli");
107
+
108
+ // The bundle advertises the REACHABLE runtime URL, not loopback.
109
+ const out = JSON.parse(logs.join("\n"));
110
+ expect(out.gatewayUrl).toBe(RUNTIME_URL);
111
+ expect(out.assistantId).toBe("self");
112
+ expect(out.token).toBe("test-access-token");
113
+ expect(out.deviceId).toBe(body.deviceId);
114
+ });
115
+
116
+ test("resolves an unquoted multi-word display name", async () => {
117
+ // Assistant whose display name has a space; passed as separate argv tokens.
118
+ writeFileSync(
119
+ join(testDir, ".vellum.lock.json"),
120
+ JSON.stringify({
121
+ assistants: [
122
+ {
123
+ assistantId: "pair-test",
124
+ name: "My Assistant",
125
+ runtimeUrl: RUNTIME_URL,
126
+ localUrl: LOCAL_URL,
127
+ cloud: "local",
128
+ },
129
+ ],
130
+ activeAssistant: "pair-test",
131
+ }),
132
+ );
133
+
134
+ let fetchCalled = false;
135
+ const origFetch = globalThis.fetch;
136
+ globalThis.fetch = (async () => {
137
+ fetchCalled = true;
138
+ return new Response(
139
+ JSON.stringify({
140
+ token: "t",
141
+ expiresAt: "2026-06-04T00:00:00.000Z",
142
+ guardianId: "g",
143
+ assistantId: "self",
144
+ }),
145
+ { status: 200, headers: { "content-type": "application/json" } },
146
+ );
147
+ }) as unknown as typeof fetch;
148
+ const logSpy = spyOn(console, "log").mockImplementation(() => {});
149
+
150
+ process.argv = ["bun", "vellum", "pair", "My", "Assistant", "--json"];
151
+ try {
152
+ await pair();
153
+ } finally {
154
+ logSpy.mockRestore();
155
+ globalThis.fetch = origFetch;
156
+ }
157
+
158
+ // Resolution succeeded (no exit), so the mint request was made.
159
+ expect(fetchCalled).toBe(true);
160
+ });
161
+
162
+ test("refuses to advertise a loopback URL without --url (suggests the assistant's own port)", async () => {
163
+ // Local hatch on a NON-default gateway port (e.g. a 2nd instance).
164
+ const LOOPBACK_CUSTOM = "http://127.0.0.1:7842";
165
+ writeFileSync(
166
+ join(testDir, ".vellum.lock.json"),
167
+ JSON.stringify({
168
+ assistants: [
169
+ {
170
+ assistantId: "pair-test",
171
+ runtimeUrl: LOOPBACK_CUSTOM,
172
+ localUrl: LOOPBACK_CUSTOM,
173
+ cloud: "local",
174
+ },
175
+ ],
176
+ activeAssistant: "pair-test",
177
+ }),
178
+ );
179
+
180
+ let fetchCalled = false;
181
+ const origFetch = globalThis.fetch;
182
+ globalThis.fetch = (async () => {
183
+ fetchCalled = true;
184
+ return new Response("{}", { status: 200 });
185
+ }) as unknown as typeof fetch;
186
+ const errors: string[] = [];
187
+ const errSpy = spyOn(console, "error").mockImplementation(
188
+ (...a: unknown[]) => {
189
+ errors.push(a.join(" "));
190
+ },
191
+ );
192
+ const exitSpy = spyOn(process, "exit").mockImplementation(((
193
+ code?: number,
194
+ ) => {
195
+ throw new Error(`exit:${code}`);
196
+ }) as never);
197
+
198
+ process.argv = ["bun", "vellum", "pair"];
199
+ let exited = false;
200
+ try {
201
+ await pair();
202
+ } catch (e) {
203
+ exited = (e as Error).message === "exit:1";
204
+ } finally {
205
+ errSpy.mockRestore();
206
+ exitSpy.mockRestore();
207
+ globalThis.fetch = origFetch;
208
+ }
209
+
210
+ // The suggested --url uses the assistant's actual port, not the default.
211
+ expect(errors.join("\n")).toContain(":7842");
212
+ expect(errors.join("\n")).not.toContain(":7830");
213
+
214
+ // Exited with an error before minting — no token created for a dead URL.
215
+ expect(exited).toBe(true);
216
+ expect(fetchCalled).toBe(false);
217
+ });
218
+
219
+ test("--url override allows pairing even when the runtime URL is loopback", async () => {
220
+ writeFileSync(
221
+ join(testDir, ".vellum.lock.json"),
222
+ JSON.stringify({
223
+ assistants: [
224
+ {
225
+ assistantId: "pair-test",
226
+ runtimeUrl: LOCAL_URL,
227
+ localUrl: LOCAL_URL,
228
+ cloud: "local",
229
+ },
230
+ ],
231
+ activeAssistant: "pair-test",
232
+ }),
233
+ );
234
+
235
+ const calls: Array<[string, RequestInit]> = [];
236
+ const origFetch = globalThis.fetch;
237
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
238
+ calls.push([url, init]);
239
+ return new Response(
240
+ JSON.stringify({
241
+ token: "t",
242
+ expiresAt: "2026-06-04T00:00:00.000Z",
243
+ guardianId: "g",
244
+ assistantId: "self",
245
+ }),
246
+ { status: 200, headers: { "content-type": "application/json" } },
247
+ );
248
+ }) as unknown as typeof fetch;
249
+ const logs: string[] = [];
250
+ const logSpy = spyOn(console, "log").mockImplementation(
251
+ (...a: unknown[]) => {
252
+ logs.push(a.join(" "));
253
+ },
254
+ );
255
+
256
+ const OVERRIDE = "https://abc123.ngrok.app";
257
+ process.argv = ["bun", "vellum", "pair", "--url", OVERRIDE, "--json"];
258
+ try {
259
+ await pair();
260
+ } finally {
261
+ logSpy.mockRestore();
262
+ globalThis.fetch = origFetch;
263
+ }
264
+
265
+ // Mint still over loopback; bundle advertises the override.
266
+ expect(calls).toHaveLength(1);
267
+ expect(calls[0][0]).toBe(`${LOCAL_URL}/v1/pair`);
268
+ const out = JSON.parse(logs.join("\n"));
269
+ expect(out.gatewayUrl).toBe(OVERRIDE);
270
+ });
271
+ });